Introduce MarkupView widget (#264)

Co-authored-by: Bleak Grey <bleakgrey@gmail.com>
This commit is contained in:
Bleak Grey 2021-02-02 12:16:59 +03:00 committed by GitHub
parent 3cf8d9ddd6
commit 02f168e19d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 273 additions and 46 deletions

View File

@ -19,6 +19,7 @@ Simple [Mastodon](https://github.com/tootsuite/mastodon) client for Linux
valac | 0.48
libglib-2.0-dev | 2.30.0
libjson-glib-dev | 1.4.4
libxml2-dev | 2.9.10
libgee-0.8-dev | 0.8.5
libsoup2.4-dev | 2.64
libgtk-3-dev | 3.22.0

View File

@ -58,3 +58,10 @@
.ttl-large-body {
font-size: 110%;
}
.ttl-code {
font-family: monospace;
padding: 12px;
background: rgba(150,150,150,.1);
border-radius: 6px;
}

View File

@ -67,6 +67,7 @@
<property name="can_focus">False</property>
<property name="vhomogeneous">False</property>
<property name="transition_type">crossfade</property>
<property name="interpolate_size">True</property>
<child>
<object class="GtkBox" id="status">
<property name="visible">True</property>

View File

@ -100,16 +100,14 @@
<property name="activatable">False</property>
<property name="selectable">False</property>
<child>
<object class="TootleWidgetsRichLabel" id="note">
<object class="TootleWidgetsMarkupView" id="note">
<property name="visible">True</property>
<property name="wrap">True</property>
<property name="can_focus">False</property>
<property name="margin_left">8</property>
<property name="margin_right">8</property>
<property name="margin_top">8</property>
<property name="margin_bottom">8</property>
<property name="selectable">True</property>
<property name="width_chars">25</property>
</object>
</child>
</object>

View File

@ -48,6 +48,7 @@
<property name="wrap">True</property>
<property name="wrap_mode">word-char</property>
<property name="xalign">0</property>
<property name="markup">allow</property>
</object>
<packing>
<property name="left_attach">2</property>

View File

@ -57,6 +57,7 @@
<property name="xalign">0</property>
<property name="margin-bottom">8</property>
<property name="track_visited_links">false</property>
<property name="markup">trust</property>
<style>
<class name="title-4"/>
</style>
@ -91,13 +92,14 @@
</packing>
</child>
<child>
<object class="TootleWidgetsRichLabel" id="handle_label">
<object class="GtkLabel" id="handle_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="no">Handle</property>
<property name="opacity">0.5</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property>
<property name="xalign">0</property>
<style>
<class name="body"/>
</style>
@ -143,11 +145,12 @@
</packing>
</child>
<child>
<object class="TootleWidgetsRichLabel" id="date_label">
<object class="GtkLabel" id="date_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="no">Yesterday</property>
<property name="opacity">0.5</property>
<property name="xalign">0</property>
<style>
<class name="body"/>
</style>
@ -241,15 +244,10 @@
<property name="orientation">vertical</property>
<property name="spacing">8</property>
<child>
<object class="TootleWidgetsRichLabel" id="content">
<object class="TootleWidgetsMarkupView" id="content">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="label">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.</property>
<property name="wrap">True</property>
<property name="wrap_mode">word-char</property>
<property name="width_chars">15</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>

View File

@ -96,6 +96,8 @@ executable(
'src/Widgets/Attachment/Slot.vala',
'src/Widgets/Attachment/Picture.vala',
'src/Widgets/AdaptiveButton.vala',
'src/Widgets/MarkupPolicy.vala',
'src/Widgets/MarkupView.vala',
'src/Dialogs/ISavedWindow.vala',
'src/Dialogs/NewAccount.vala',
'src/Dialogs/MainWindow.vala',
@ -126,6 +128,7 @@ executable(
dependency('gee-0.8', version: '>=0.8.5'),
dependency('libsoup-2.4'),
dependency('json-glib-1.0', version: '>=1.4.4'),
dependency('libxml-2.0'),
libhandy_dep,
],
install: true,

View File

@ -63,7 +63,7 @@ public class Tootle.API.Attachment : Entity {
var descr_param = "";
if (descr != null && descr.replace (" ", "") != "") {
descr_param = "?description=" + Html.uri_encode (descr);
descr_param = "?description=" + HtmlUtils.uri_encode (descr);
}
var buffer = new Soup.Buffer.take (contents);

View File

@ -68,9 +68,9 @@ public class Tootle.API.Status : Entity, Widgetizable {
if (account.note == "")
content = "";
else if ("\n" in account.note)
content = Html.remove_tags (account.note.split ("\n")[0]);
content = account.note.split ("\n")[0];
else
content = Html.remove_tags (account.note);
content = account.note;
}
public override Gtk.Widget to_widget () {

View File

@ -110,7 +110,7 @@ public class Tootle.Dialogs.Compose : Hdy.Window {
cw.text = status.spoiler_text;
cw_button.active = true;
}
content.buffer.text = Html.remove_tags (status.content);
content.buffer.text = HtmlUtils.remove_tags (status.content);
validate ();
set_media_mode (status.has_media ());
@ -291,11 +291,11 @@ public class Tootle.Dialogs.Compose : Hdy.Window {
var req = new Request.POST (@"/api/v1/statuses?$media_param")
.with_account (accounts.active)
.with_param ("visibility", visibility_popover.selected.to_string ())
.with_param ("status", Html.uri_encode (status.content));
.with_param ("status", HtmlUtils.uri_encode (status.content));
if (cw_button.active) {
req.with_param ("sensitive", "true");
req.with_param ("spoiler_text", Html.uri_encode (cw.text));
req.with_param ("spoiler_text", HtmlUtils.uri_encode (cw.text));
}
if (status.in_reply_to_id != null)
req.with_param ("in_reply_to_id", status.in_reply_to_id);

View File

@ -1,10 +1,12 @@
public class Tootle.Html {
public class Tootle.HtmlUtils {
public const string FALLBACK_TEXT = _("[ There was an error parsing this text :c ]");
public static string remove_tags (string content) {
try {
//TODO: remove this when simplify() uses the HTML parsing class
var fixed_paragraphs = simplify (content);
var all_tags = new Regex ("<(.|\n)*?>", RegexCompileFlags.CASELESS);
return Widgets.RichLabel.restore_entities (all_tags.replace (fixed_paragraphs, -1, 0, ""));
}
@ -14,6 +16,8 @@ public class Tootle.Html {
}
}
//TODO: Perhaps this should use the HTML parser class
// since we depend on it anyway
public static string simplify (string str) {
try {
var divided = str
@ -32,8 +36,8 @@ public class Tootle.Html {
return simplified;
}
catch (Error e) {
warning (e.message);
return FALLBACK_TEXT;
warning (@"Can't simplify string \"$str\":\n$(e.message)");
return remove_tags (str);
}
}

View File

@ -63,13 +63,13 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener {
}
void show_notification (API.Notification obj) {
var title = Html.remove_tags (obj.kind.get_desc (obj.account));
var title = HtmlUtils.remove_tags (obj.kind.get_desc (obj.account));
var notification = new GLib.Notification (title);
if (obj.status != null) {
var body = "";
body += domain;
body += "\n";
body += Html.remove_tags (obj.status.content);
body += HtmlUtils.remove_tags (obj.status.content);
notification.set_body (body);
}

View File

@ -47,9 +47,9 @@ public class Tootle.Views.Profile : Views.Timeline {
profile.bind_property ("display-name", handle, "text", BindingFlags.SYNC_CREATE);
note_row = builder.get_object ("note_row") as ListBoxRow;
var note = builder.get_object ("note") as Widgets.RichLabel;
profile.bind_property ("note", note, "text", BindingFlags.SYNC_CREATE, (b, src, ref target) => {
var text = Html.simplify ((string) src);
var note = builder.get_object ("note") as Widgets.MarkupView;
profile.bind_property ("note", note, "content", BindingFlags.SYNC_CREATE, (b, src, ref target) => {
var text = (string) src;
target.set_string (text);
note_row.visible = text != "";
return true;

View File

@ -19,7 +19,7 @@ public class Tootle.Widgets.Conversation : Widgets.Status {
owned get {
var label = "";
foreach (API.Account account in conversation.accounts) {
label += "<b>" + Html.simplify (account.display_name) + "</b>";
label += account.display_name;
if (conversation.accounts.last () != account)
label += ", ";
}

View File

@ -0,0 +1,27 @@
public enum Tootle.MarkupPolicy {
// Remove all tags from the string
DISALLOW,
// Allow markup, remove unsupported tags from the input string
ALLOW,
// Allow markup, do nothing with the input string
TRUST;
public string process (string input) {
switch (this) {
case DISALLOW:
return HtmlUtils.remove_tags (input);
case ALLOW:
return HtmlUtils.simplify (input);
default:
return input;
}
}
public void apply (Widgets.RichLabel w) {
w.use_markup = this != DISALLOW;
}
}

174
src/Widgets/MarkupView.vala Normal file
View File

@ -0,0 +1,174 @@
using Gtk;
public class Tootle.Widgets.MarkupView : Box {
public delegate void NodeFn (Xml.Node* node);
public delegate void NodeHandlerFn (MarkupView view, Xml.Node* node);
string? current_chunk = null;
string _content = "";
public string content {
get {
return _content;
}
set {
_content = value;
update_content (_content);
}
}
private bool _selectable = false;
public bool selectable {
get { return _selectable; }
set {
_selectable = value;
get_children ().foreach (w => {
var label = w as Label;
if (label != null) {
label.selectable = _selectable;
}
});
}
}
construct {
orientation = Orientation.VERTICAL;
spacing = 12;
}
void update_content (string content) {
current_chunk = null;
get_children ().foreach (w => {
w.destroy ();
});
var doc = Html.Doc.read_doc (content, "", "utf8");
if (doc != null) {
var root = doc->get_root_element ();
if (root != null) {
default_handler (this, root);
}
}
delete doc;
visible = get_children ().length () > 0;
}
static void traverse (Xml.Node* root, owned NodeFn cb) {
for (var iter = root->children; iter != null; iter = iter->next) {
cb (iter);
}
}
static void traverse_and_handle (MarkupView v, Xml.Node* root, owned NodeHandlerFn handler) {
traverse (root, node => {
handler (v, node);
});
}
void commit_chunk () {
if (current_chunk != null && current_chunk != "") {
var label = new RichLabel (current_chunk) {
visible = true,
markup = MarkupPolicy.TRUST,
selectable = _selectable
};
pack_start (label);
}
current_chunk = null;
}
void write_chunk (string? chunk) {
if (chunk == null) return;
if (current_chunk == null)
current_chunk = chunk;
else
current_chunk += chunk;
}
public static void default_handler (MarkupView v, Xml.Node* root) {
switch (root->name) {
case "html":
case "span":
case "markup":
case "pre":
case "ul":
case "ol":
traverse_and_handle (v, root, default_handler);
break;
case "body":
traverse_and_handle (v, root, default_handler);
v.commit_chunk ();
break;
case "p":
// Don't add spacing if this is the first paragraph
if (v.current_chunk != "" && v.current_chunk != null)
v.write_chunk ("\n\n");
traverse_and_handle (v, root, default_handler);
break;
case "code":
case "blockquote":
v.commit_chunk ();
var text = "";
traverse (root, (node) => {
switch (node->name) {
case "text":
text += node->content;
break;
default:
break;
}
});
var label = new RichLabel (text) {
visible = true,
markup = MarkupPolicy.DISALLOW
};
label.get_style_context ().add_class ("ttl-code");
v.pack_start (label);
break;
case "a":
var href = root->get_prop ("href");
if (href != null) {
v.write_chunk ("<a href='" + GLib.Markup.escape_text (href) + "'>");
traverse_and_handle (v, root, default_handler);
v.write_chunk ("</a>");
}
break;
case "b":
case "i":
case "u":
case "s":
case "sup":
case "sub":
v.write_chunk (@"<$(root->name)>");
traverse_and_handle (v, root, default_handler);
v.write_chunk (@"</$(root->name)>");
break;
case "li":
v.write_chunk ("\n");
traverse_and_handle (v, root, default_handler);
break;
case "br":
v.write_chunk ("\n");
break;
case "text":
if (root->content != null)
v.write_chunk (GLib.Markup.escape_text (root->content));
break;
default:
warning (@"Unknown HTML tag: \"$(root->name)\"");
traverse_and_handle (v, root, default_handler);
break;
}
}
}

View File

@ -3,19 +3,30 @@ using Gee;
public class Tootle.Widgets.RichLabel : Label {
// TODO: We can parse <a> tags and extract resolvable URIs now
public weak ArrayList<API.Mention>? mentions;
MarkupPolicy _markup = DISALLOW;
public MarkupPolicy markup {
get {
return _markup;
}
set {
_markup = value;
_markup.apply (this);
}
}
public string text {
get {
return this.label;
}
set {
this.label = escape_entities (Html.simplify (value));
this.label = markup.process (value);
}
}
construct {
use_markup = true;
xalign = 0;
wrap_mode = Pango.WrapMode.WORD_CHAR;
justify = Justification.LEFT;

View File

@ -42,16 +42,16 @@ public class Tootle.Widgets.Status : ListBoxRow {
[GtkChild] public Widgets.Avatar avatar;
[GtkChild] protected Widgets.RichLabel name_label;
[GtkChild] protected Widgets.RichLabel handle_label;
[GtkChild] protected Label handle_label;
[GtkChild] protected Box indicators;
[GtkChild] protected Widgets.RichLabel date_label;
[GtkChild] protected Label date_label;
[GtkChild] protected Image pin_indicator;
[GtkChild] protected Image indicator;
[GtkChild] protected Box content_column;
[GtkChild] protected Stack spoiler_stack;
[GtkChild] protected Box content_box;
[GtkChild] protected Widgets.RichLabel content;
[GtkChild] protected Widgets.MarkupView content;
[GtkChild] protected Widgets.Attachment.Box attachments;
[GtkChild] protected Button spoiler_button;
[GtkChild] protected Widgets.RichLabel spoiler_label;
@ -84,7 +84,7 @@ public class Tootle.Widgets.Status : ListBoxRow {
public string title_text {
owned get {
return Html.simplify (status.formal.account.display_name);
return status.formal.account.display_name;
}
}
@ -140,9 +140,9 @@ public class Tootle.Widgets.Status : ListBoxRow {
reply_button_icon.icon_name = "mail-reply-sender-symbolic";
bind_property ("spoiler-text", spoiler_label, "text", BindingFlags.SYNC_CREATE);
status.formal.bind_property ("content", content, "text", BindingFlags.SYNC_CREATE);
status.formal.bind_property ("content", content, "content", BindingFlags.SYNC_CREATE);
bind_property ("title_text", name_label, "text", BindingFlags.SYNC_CREATE);
bind_property ("subtitle_text", handle_label, "text", BindingFlags.SYNC_CREATE);
bind_property ("subtitle_text", handle_label, "label", BindingFlags.SYNC_CREATE);
bind_property ("date", date_label, "label", BindingFlags.SYNC_CREATE);
status.formal.bind_property ("pinned", pin_indicator, "visible", BindingFlags.SYNC_CREATE);
status.formal.bind_property ("account", avatar, "account", BindingFlags.SYNC_CREATE);
@ -166,9 +166,11 @@ public class Tootle.Widgets.Status : ListBoxRow {
if (status.id == "") {
actions.destroy ();
date_label.destroy ();
content.single_line_mode = true;
content.lines = 2;
content.ellipsize = Pango.EllipsizeMode.END;
//TODO: this
// content.single_line_mode = true;
// content.lines = 2;
// content.ellipsize = Pango.EllipsizeMode.END;
}
if (!attachments.populate (status.formal.media_attachments) || status.id == "") {
@ -178,7 +180,7 @@ public class Tootle.Widgets.Status : ListBoxRow {
menu_button.clicked.connect (open_menu);
}
public Status (API.Status status, API.NotificationType? kind = null) {
public Status (owned API.Status status, API.NotificationType? kind = null) {
Object (
status: status,
kind: kind
@ -216,7 +218,7 @@ public class Tootle.Widgets.Status : ListBoxRow {
item_copy_link.activate.connect (() => Desktop.copy (status.formal.url));
var item_copy = new Gtk.MenuItem.with_label (_("Copy Text"));
item_copy.activate.connect (() => {
var sanitized = Html.remove_tags (status.formal.content);
var sanitized = HtmlUtils.remove_tags (status.formal.content);
Desktop.copy (sanitized);
});
@ -261,14 +263,14 @@ public class Tootle.Widgets.Status : ListBoxRow {
public void expand_root () {
activatable = false;
content.selectable = true;
content.get_style_context ().add_class ("ttl-large-body");
content.selectable = true;
content.get_style_context ().add_class ("ttl-large-body");
var parent = content_column.get_parent () as Container;
var left_attach = parent.find_child_property ("left-attach");
var width = parent.find_child_property ("width");
parent.set_child_property (content_column, 1, 0, left_attach);
parent.set_child_property (content_column, 3, 2, width);
var parent = content_column.get_parent () as Container;
var left_attach = parent.find_child_property ("left-attach");
var width = parent.find_child_property ("width");
parent.set_child_property (content_column, 1, 0, left_attach);
parent.set_child_property (content_column, 3, 2, width);
}
public void install_thread_line () {