Skip to content
Merged
Show file tree
Hide file tree
Changes from 60 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
8d8a68e
Granite.HyperTextView
marbetschar Jun 24, 2021
d8a7f4b
Make Linter Happy
marbetschar Jun 24, 2021
52354e9
Lint Me Happy
marbetschar Jun 24, 2021
cfb3984
Eventually the Linter shuts up
marbetschar Jun 24, 2021
d6159c3
Ignore parentheses, support local file paths
marbetschar Jun 25, 2021
e28dcc5
Ignore < and >, improves HTML/XML handling.
marbetschar Jun 25, 2021
06024ab
Improved Markdown Support
marbetschar Jun 25, 2021
de7f580
Added mailto: prefix check
marbetschar Jun 25, 2021
4025a13
Improved Performance for Large Texts
marbetschar Jun 27, 2021
db7a52b
Some text adjustments
marbetschar Jun 27, 2021
0d189a4
Fixed Linter error
marbetschar Jun 27, 2021
7aadf7e
Fixed Linting Error
marbetschar Jun 27, 2021
2d7e7dc
HyperTextView packed into ScrolledWindow
marbetschar Jun 27, 2021
ae48a19
Dealing with multibyte characters
marbetschar Jun 29, 2021
c5126bc
Fixed whitespace
marbetschar Jun 29, 2021
203c370
Dropped potential harmful max param
marbetschar Jun 29, 2021
a80f5c3
Add mailto protocol if needed
marbetschar Jun 30, 2021
d19ee18
Control + Click
marbetschar Jun 30, 2021
002fa00
Fixed deprecated window.get_pointer
marbetschar Jul 1, 2021
6132d1d
Bind key_press/key_release to toplevel window
marbetschar Jul 1, 2021
eff3a8b
Bind key_press/key_release signals to toplevel window
marbetschar Jul 1, 2021
b5180f4
Made toplevel_window signal handling more robust
marbetschar Jul 1, 2021
5e9dfd1
Disconnect from toplevel window seems not necessary
marbetschar Jul 1, 2021
e3f0864
Added warning in case toplevel window is missing
marbetschar Jul 1, 2021
59324d7
Make toplevel_window local variable
marbetschar Jul 1, 2021
ddf7600
Fixed Linting Errors
marbetschar Jul 1, 2021
a17292f
Removed empty lines
marbetschar Jul 1, 2021
39dc366
Merge branch 'master' into hypertextview
marbetschar Jul 5, 2021
c9d8f48
Dropped on comment and use single star
marbetschar Jul 6, 2021
c00f232
Merge branch 'master' into hypertextview
marbetschar Jul 6, 2021
cb8ed55
Require http(s):// but made mailto: optional
marbetschar Jul 7, 2021
c4a91df
Linter kills me
marbetschar Jul 7, 2021
69d6cf8
Added missing chars to http charset
marbetschar Jul 7, 2021
c7cca7a
Allow plus and hash signs
marbetschar Jul 7, 2021
3a4614a
Merge branch 'master' into hypertextview
marbetschar Jul 12, 2021
f8fd0a5
Merge branch 'master' into hypertextview
marbetschar Jul 13, 2021
5136977
Merge branch 'master' into hypertextview
marbetschar Jul 19, 2021
6a0fb0c
Merge branch 'master' into hypertextview
Jul 19, 2021
0055516
Changed "Unable" to "Could not"
marbetschar Jul 21, 2021
ae41c78
Use magic const to make full buffer rescan logic more obvious
marbetschar Jul 21, 2021
f7843eb
Updated header label to make functionality more obvious
marbetschar Jul 21, 2021
b92b415
Merge branch 'master' into hypertextview
marbetschar Jul 21, 2021
9e5a9a4
Merge branch 'master' into hypertextview
marbetschar Jul 27, 2021
e6c2973
Merge branch 'master' into hypertextview
cassidyjames Jul 27, 2021
df91697
Merge branch 'master' into hypertextview
marbetschar Aug 8, 2021
8087eff
Merge branch 'master' into hypertextview
marbetschar Aug 17, 2021
797519f
Use inline namespacing
marbetschar Aug 26, 2021
017c210
Using inline namespace
marbetschar Aug 26, 2021
3b3a10d
Added changes from Calendar tests
marbetschar Aug 26, 2021
5700bff
Merge branch 'master' into hypertextview
marbetschar Aug 26, 2021
050585b
Use Granite prefix
marbetschar Aug 26, 2021
24b13fe
Merge branch 'master' into hypertextview
marbetschar Aug 30, 2021
4356422
Merge branch 'master' into hypertextview
marbetschar Sep 6, 2021
edccbed
Addressed naming concerns
marbetschar Sep 6, 2021
828ae63
Fixed issue where initialization sometimes failed
marbetschar Sep 6, 2021
dd27f43
Fixed preview of HyperTextView caused by name-clash
marbetschar Sep 6, 2021
74831fb
Merge branch 'master' into hypertextview
marbetschar Sep 14, 2021
304db29
Added suggestions of @mcclurgm
marbetschar Sep 19, 2021
be393e1
Added popover fix from @jeremypw
marbetschar Sep 20, 2021
cd17ff7
Merge branch 'master' into hypertextview
marbetschar Sep 23, 2021
c2488e9
Merge branch 'master' into hypertextview
cassidyjames Sep 29, 2021
684f2d8
Update lib/Widgets/HyperTextView.vala
marbetschar Oct 4, 2021
cc2b6e8
Merge branch 'master' into hypertextview
marbetschar Oct 4, 2021
9d11b75
Merge branch 'master' into hypertextview
marbetschar Oct 10, 2021
8c9a957
Merge branch 'master' into hypertextview
marbetschar Oct 25, 2021
0a04662
Merge branch 'master' into hypertextview
cassidyjames Oct 26, 2021
7d667c1
Merge branch 'master' into hypertextview
danirabbit Nov 4, 2021
d63967a
Update HyperTextView.vala
danirabbit Nov 4, 2021
87dbe2e
Update granite.appdata.xml.in
danirabbit Nov 4, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demo/GraniteDemo.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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 ();
Expand All @@ -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");
Expand Down
42 changes: 42 additions & 0 deletions demo/Views/HyperTextViewGrid.vala
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 ();
}
}
1 change: 1 addition & 0 deletions demo/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
324 changes: 324 additions & 0 deletions lib/Widgets/HyperTextView.vala
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
*/
Comment thread
marbetschar marked this conversation as resolved.
Outdated
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;
}
}
1 change: 1 addition & 0 deletions lib/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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',
Expand Down