From ed3f9f9e6927180fc9aed6a7e9b2b341658555cb Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Sun, 8 Sep 2024 04:00:04 +0300 Subject: [PATCH] feat(composer): focus picker (#1101) --- data/gresource.xml | 1 + .../actions/tuba-camera-focus-symbolic.svg | 2 + data/style.css | 10 + src/API/Attachment.vala | 9 + src/Dialogs/Composer/AttachmentsPage.vala | 11 +- .../Composer/AttachmentsPageAttachment.vala | 441 +++++++++++++----- src/Dialogs/Composer/Dialog.vala | 43 +- src/Widgets/AudioPlayer/Controls.vala | 12 - src/Widgets/FocusPicker.vala | 191 ++++++++ src/Widgets/meson.build | 1 + 10 files changed, 581 insertions(+), 140 deletions(-) create mode 100644 data/icons/scalable/actions/tuba-camera-focus-symbolic.svg create mode 100644 src/Widgets/FocusPicker.vala diff --git a/data/gresource.xml b/data/gresource.xml index 0fe6191a8..a4d2e5a0c 100644 --- a/data/gresource.xml +++ b/data/gresource.xml @@ -68,6 +68,7 @@ icons/scalable/actions/tuba-radio-checked-symbolic.svg icons/scalable/actions/tuba-mail-unread-symbolic.svg icons/scalable/actions/tuba-starred-symbolic.svg + icons/scalable/actions/tuba-camera-focus-symbolic.svg gtk/help-overlay.ui diff --git a/data/icons/scalable/actions/tuba-camera-focus-symbolic.svg b/data/icons/scalable/actions/tuba-camera-focus-symbolic.svg new file mode 100644 index 000000000..157e18087 --- /dev/null +++ b/data/icons/scalable/actions/tuba-camera-focus-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/style.css b/data/style.css index 9729f8b2d..554bdb18c 100644 --- a/data/style.css +++ b/data/style.css @@ -724,3 +724,13 @@ popover.mini-profile > contents { .report-status:hover .attachment-picture { filter: none; } + +.focuspickerdialog .last-row { + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +.focuspickerdialog .focus-picker { + border-radius: 9999px; + padding: 4px; +} diff --git a/src/API/Attachment.vala b/src/API/Attachment.vala index fb7b3c33e..c95f5c881 100644 --- a/src/API/Attachment.vala +++ b/src/API/Attachment.vala @@ -1,9 +1,18 @@ public class Tuba.API.Attachment : Entity, Widgetizable { + public class Meta : Entity { + public class Focus : Entity { + public float x { get; set; } + public float y { get; set; } + } + + public Focus? focus { get; set; } + } public string id { get; set; } public string kind { get; set; default = "unknown"; } public string url { get; set; } public string? description { get; set; } + public Meta? meta { get; set; } public string? blurhash { get; set; default=null; } private string? t_preview_url { get; set; } public string? preview_url { diff --git a/src/Dialogs/Composer/AttachmentsPage.vala b/src/Dialogs/Composer/AttachmentsPage.vala index 0b6b0e668..ec0ba4832 100644 --- a/src/Dialogs/Composer/AttachmentsPage.vala +++ b/src/Dialogs/Composer/AttachmentsPage.vala @@ -479,10 +479,17 @@ public class Tuba.AttachmentsPage : ComposerPage { for (var i = 0; i < attachments.get_n_items (); i++) { var attachment = attachments.get_item (i) as API.Attachment; - var attachment_page_attachment_alt = ((AttachmentsPageAttachment) list.get_row_at_index (i).child).alt_text; + var page_attachment = ((AttachmentsPageAttachment) list.get_row_at_index (i).child); + var attachment_page_attachment_alt = page_attachment.alt_text; attachment.description = attachment_page_attachment_alt; - status.add_media (attachment.id, attachment.description); + + string? focus = null; + if (attachment.meta != null && attachment.meta.focus != null) { + focus = "%.2f,%.2f".printf (page_attachment.pos_x, page_attachment.pos_y); + } + + status.add_media (attachment.id, attachment.description, focus); status.media_attachments.add (attachment); } status.sensitive = media_sensitive; diff --git a/src/Dialogs/Composer/AttachmentsPageAttachment.vala b/src/Dialogs/Composer/AttachmentsPageAttachment.vala index 2af3ae323..4bf310f30 100644 --- a/src/Dialogs/Composer/AttachmentsPageAttachment.vala +++ b/src/Dialogs/Composer/AttachmentsPageAttachment.vala @@ -1,15 +1,275 @@ public class Tuba.AttachmentsPageAttachment : Widgets.Attachment.Item { + private class UtilityPanel : Adw.Dialog { + ~UtilityPanel () { + debug ("Destroying UtilityPanel"); + } + + public signal void saved (); + + protected Adw.ToastOverlay toast_overlay; + protected Adw.HeaderBar headerbar; + protected Adw.ToolbarView toolbar_view; + protected Gtk.Button save_btn; + + private bool _working = false; + public bool working { + get { return _working; } + set { + _working = value; + + save_btn.sensitive = !_working && _can_save; + } + } + + private bool _can_save = false; + public bool can_save { + get { return _can_save; } + set { + _can_save = value; + + save_btn.sensitive = !_working && _can_save; + } + } + + construct { + toast_overlay = new Adw.ToastOverlay () { + vexpand = true, + hexpand = true + }; + + this.child = toast_overlay; + this.content_width = 400; + this.content_height = 300; + + toolbar_view = new Adw.ToolbarView (); + headerbar = new Adw.HeaderBar (); + + save_btn = new Gtk.Button.with_label (_("Save")); + save_btn.add_css_class ("suggested-action"); + headerbar.pack_end (save_btn); + + toolbar_view.add_top_bar (headerbar); + + toast_overlay.child = toolbar_view; + } + + public void show_toast (string text) { + toast_overlay.add_toast (new Adw.Toast (text) { + timeout = 5 + }); + } + + public void on_error (string text) { + this.working = false; + show_toast (text); + } + } + + private class AltTextDialog : UtilityPanel { + ~AltTextDialog () { + debug ("Destroying AltTextDialog"); + } + + const int ALT_MAX_CHARS = 1500; + GtkSource.View alt_editor; + Gtk.Label dialog_char_counter; + + public string get_alt_text () { + return alt_editor.buffer.text; + } + + public int get_char_count () { + return alt_editor.buffer.get_char_count (); + } + + construct { + this.title = _("Alternative text for attachment"); + + alt_editor = new GtkSource.View () { + vexpand = true, + hexpand = true, + top_margin = 6, + right_margin = 6, + bottom_margin = 6, + left_margin = 6, + pixels_below_lines = 6, + accepts_tab = false, + wrap_mode = Gtk.WrapMode.WORD_CHAR + }; + alt_editor.remove_css_class ("view"); + alt_editor.add_css_class ("reset"); + + Adw.StyleManager.get_default ().notify["dark"].connect (update_style_scheme); + update_style_scheme (); + + #if LIBSPELLING + var adapter = new Spelling.TextBufferAdapter ((GtkSource.Buffer) alt_editor.buffer, Spelling.Checker.get_default ()); + + alt_editor.extra_menu = adapter.get_menu_model (); + alt_editor.insert_action_group ("spelling", adapter); + adapter.enabled = true; + #endif + + var scroller = new Gtk.ScrolledWindow () { + hexpand = true, + vexpand = true, + child = alt_editor + }; + + var bottom_bar = new Gtk.ActionBar (); + dialog_char_counter = new Gtk.Label ("") { + margin_end = 6, + margin_top = 14, + margin_bottom = 14, + tooltip_text = _("Characters Left"), + css_classes = { "heading" } + }; + bottom_bar.pack_end (dialog_char_counter); + + toolbar_view.set_content (scroller); + toolbar_view.add_bottom_bar (bottom_bar); + + alt_editor.buffer.changed.connect (on_alt_editor_buffer_change); + save_btn.clicked.connect (on_save); + } + + protected void update_style_scheme () { + var manager = GtkSource.StyleSchemeManager.get_default (); + string scheme_name = "Adwaita"; + if (Adw.StyleManager.get_default ().dark) scheme_name += "-dark"; + ((GtkSource.Buffer) alt_editor.buffer).style_scheme = manager.get_scheme (scheme_name); + } + + private void on_save () { + this.working = true; + saved (); + } + + public AltTextDialog (string alt_text) { + dialog_char_counter.label = remaining_alt_chars (alt_text != null ? alt_text.length : 0); + + if (alt_text != null) { + this.can_save = validate (alt_text.length); + alt_editor.buffer.text = alt_text; + } else { + this.can_save = false; + } + } + + public static bool validate (int text_size) { + return text_size <= ALT_MAX_CHARS; + } + + private void on_alt_editor_buffer_change () { + var t_val = validate (alt_editor.buffer.get_char_count ()); + this.can_save = t_val; + dialog_char_counter.label = remaining_alt_chars (alt_editor.buffer.get_char_count ()); + if (t_val) { + dialog_char_counter.remove_css_class ("error"); + } else { + dialog_char_counter.add_css_class ("error"); + } + } + + protected string remaining_alt_chars (int text_size) { + return (ALT_MAX_CHARS - text_size).to_string (); + } + } + + private class FocusPickerDialog : UtilityPanel { + ~FocusPickerDialog () { + debug ("Destroying FocusPickerDialog"); + } + + public float pos_x { get; set; default = 0.0f; } + public float pos_y { get; set; default = 0.0f; } + + construct { + this.add_css_class ("focuspickerdialog"); + this.follows_content_size = true; + this.title = _("Focal point for attachment thumbnail"); + save_btn.clicked.connect (on_save); + + var pos_x_scale = new Adw.SpinRow.with_range (-1.0, 1.0, 0.1) { + update_policy = Gtk.SpinButtonUpdatePolicy.IF_VALID, + snap_to_ticks = true, + numeric = true, + // translators: Title for focus picker scale + title = _("Horizontal Position"), + // translators: Subtitle for focus picker scale + // subtitle = _("The value equals to the X axis point of the desired position") + }; + pos_x_scale.bind_property ( + "value", + this, + "pos-x", + GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL, + on_scale_value_changed, + on_pos_value_changed + ); + + var pos_y_scale = new Adw.SpinRow.with_range (-1.0, 1.0, 0.1) { + update_policy = Gtk.SpinButtonUpdatePolicy.IF_VALID, + snap_to_ticks = true, + numeric = true, + // translators: Title for focus picker scale + title = _("Vertical Position"), + // translators: Subtitle for focus picker scale + // subtitle = _("The value equals to the Y axis point of the desired position") + }; + pos_y_scale.add_css_class ("last-row"); + pos_y_scale.bind_property ( + "value", + this, + "pos-y", + GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL, + on_scale_value_changed, + on_pos_value_changed + ); + + toolbar_view.add_bottom_bar (pos_x_scale); + toolbar_view.add_bottom_bar (pos_y_scale); + } + + private void on_save () { + this.working = true; + saved (); + } + + public FocusPickerDialog (Gdk.Paintable paintable, float pos_x, float pos_y) { + this.pos_x = pos_x; + this.pos_y = pos_y; + + var focus_picker = new Widgets.FocusPicker (paintable); + focus_picker.bind_property ("pos-x", this, "pos-x", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + focus_picker.bind_property ("pos-y", this, "pos-y", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + + toolbar_view.content = focus_picker; + } + + private bool on_scale_value_changed (Binding binding, Value from_value, ref Value to_value) { + to_value.set_float ((float) from_value.get_double ()); + return true; + } + + private bool on_pos_value_changed (Binding binding, Value from_value, ref Value to_value) { + to_value.set_double ((double) from_value.get_float ()); + return true; + } + } + + public string? alt_text { get; private set; default = null; } + public float pos_x { get; set; default = 0.0f; } + public float pos_y { get; set; default = 0.0f; } protected Gtk.Picture pic; protected File? attachment_file; - public string? alt_text { get; private set; default = null; } - private const int ALT_MAX_CHARS = 1500; private unowned Dialogs.Compose compose_dialog; protected string id; private bool edit_mode = false; ~AttachmentsPageAttachment () { - close_dialog (); + close_dialogs (); debug ("Destroying AttachmentsPageAttachment"); } @@ -25,6 +285,11 @@ public class Tuba.AttachmentsPageAttachment : Widgets.Attachment.Item { attachment_file = file; compose_dialog = dialog; + if (t_entity.meta != null && t_entity.meta.focus != null) { + this.pos_x = t_entity.meta.focus.x; + this.pos_y = t_entity.meta.focus.y; + } + pic = new Gtk.Picture () { hexpand = true, vexpand = true, @@ -57,12 +322,38 @@ public class Tuba.AttachmentsPageAttachment : Widgets.Attachment.Item { overlay.add_overlay (delete_button); delete_button.clicked.connect (on_delete_clicked); + var focus_button = new Gtk.Button () { + icon_name = "tuba-camera-focus-symbolic", + valign = Gtk.Align.START, + halign = Gtk.Align.END, + tooltip_text = _("Edit Focal Point"), + css_classes = { "ttl-status-badge" } + }; + overlay.add_overlay (focus_button); + focus_button.clicked.connect (on_focus_picker_clicked); + alt_text = t_entity.description ?? ""; update_alt_css (alt_text.length); } + FocusPickerDialog? focus_picker_dialog = null; + private void on_focus_picker_clicked () { + if (focus_picker_dialog == null) { + focus_picker_dialog = new FocusPickerDialog (pic.paintable, pos_x, pos_y); + focus_picker_dialog.saved.connect (on_save_clicked); + } + + focus_picker_dialog.present (compose_dialog); + } + + AltTextDialog? alt_text_dialog = null; private void on_alt_btn_clicked () { - create_alt_text_input_dialog ().present (compose_dialog); + if (alt_text_dialog == null) { + alt_text_dialog = new AltTextDialog (alt_text); + alt_text_dialog.saved.connect (on_save_clicked); + } + + alt_text_dialog.present (compose_dialog); } private void on_delete_clicked () { @@ -87,133 +378,53 @@ public class Tuba.AttachmentsPageAttachment : Widgets.Attachment.Item { } } - protected bool validate (int text_size) { - // text_size > 0 && - return text_size <= ALT_MAX_CHARS; - } - - protected string remaining_alt_chars (int text_size) { - return (ALT_MAX_CHARS - text_size).to_string (); - } - - GtkSource.View alt_editor; - Adw.Dialog dialog; - Gtk.Button dialog_save_btn; - Gtk.Label dialog_char_counter; - protected Adw.Dialog create_alt_text_input_dialog () { - alt_editor = new GtkSource.View () { - vexpand = true, - hexpand = true, - top_margin = 6, - right_margin = 6, - bottom_margin = 6, - left_margin = 6, - pixels_below_lines = 6, - accepts_tab = false, - wrap_mode = Gtk.WrapMode.WORD_CHAR - }; - alt_editor.remove_css_class ("view"); - alt_editor.add_css_class ("reset"); - - Adw.StyleManager.get_default ().notify["dark"].connect (update_style_scheme); - update_style_scheme (); - - #if LIBSPELLING - var adapter = new Spelling.TextBufferAdapter ((GtkSource.Buffer) alt_editor.buffer, Spelling.Checker.get_default ()); - - alt_editor.extra_menu = adapter.get_menu_model (); - alt_editor.insert_action_group ("spelling", adapter); - adapter.enabled = true; - #endif - - var scroller = new Gtk.ScrolledWindow () { - hexpand = true, - vexpand = true - }; - scroller.child = alt_editor; - - var toolbar_view = new Adw.ToolbarView (); - var headerbar = new Adw.HeaderBar (); - - var bottom_bar = new Gtk.ActionBar (); - dialog_char_counter = new Gtk.Label (remaining_alt_chars (alt_text != null ? alt_text.length : 0)) { - margin_end = 6, - margin_top = 14, - margin_bottom = 14, - tooltip_text = _("Characters Left"), - css_classes = { "heading" } - }; - bottom_bar.pack_end (dialog_char_counter); - - dialog_save_btn = new Gtk.Button.with_label (_("Save")); - dialog_save_btn.add_css_class ("suggested-action"); - dialog_save_btn.sensitive = alt_text != null && validate (alt_text.length); - headerbar.pack_end (dialog_save_btn); - - toolbar_view.add_top_bar (headerbar); - toolbar_view.set_content (scroller); - toolbar_view.add_bottom_bar (bottom_bar); - - if (alt_text != null) - alt_editor.buffer.text = alt_text; - alt_editor.buffer.changed.connect (on_alt_editor_buffer_change); - - dialog = new Adw.Dialog () { - title = _("Alternative text for attachment"), - child = toolbar_view, - content_width = 400, - content_height = 300 - }; - - dialog_save_btn.clicked.connect (on_save_clicked); - - return dialog; - } - - protected void update_style_scheme () { - var manager = GtkSource.StyleSchemeManager.get_default (); - string scheme_name = "Adwaita"; - if (Adw.StyleManager.get_default ().dark) scheme_name += "-dark"; - ((GtkSource.Buffer) alt_editor.buffer).style_scheme = manager.get_scheme (scheme_name); - } - private void on_save_clicked () { - alt_text = alt_editor.buffer.text; - update_alt_css (alt_editor.buffer.get_char_count ()); + if (alt_text_dialog != null) { + this.alt_text = alt_text_dialog.get_alt_text (); + update_alt_css (alt_text_dialog.get_char_count ()); + } + if (focus_picker_dialog != null) { + this.pos_x = focus_picker_dialog.pos_x; + this.pos_y = focus_picker_dialog.pos_y; + } + + // When editing, we can only update attachment metadata + // with the whole post if (!edit_mode) { new Request.PUT (@"/api/v1/media/$(id)") .with_account (accounts.active) .with_param ("description", alt_text) + .with_param ("focus", "%.2f,%.2f".printf (pos_x, pos_y)) + .then (() => { + close_dialogs (); + }) + .on_error ((code, message) => { + string error_text = @"$code $message"; + + if (alt_text_dialog != null) alt_text_dialog.on_error (error_text); + if (focus_picker_dialog != null) focus_picker_dialog.on_error (error_text); + }) .exec (); + } else { + close_dialogs (); } - - close_dialog (); } - private void on_alt_editor_buffer_change () { - var t_val = validate (alt_editor.buffer.get_char_count ()); - dialog_save_btn.sensitive = t_val; - dialog_char_counter.label = remaining_alt_chars (alt_editor.buffer.get_char_count ()); - if (t_val) { - dialog_char_counter.remove_css_class ("error"); - } else { - dialog_char_counter.add_css_class ("error"); + private void close_dialogs () { + if (alt_text_dialog != null) { + alt_text_dialog.force_close (); + alt_text_dialog = null; } - } - private void close_dialog () { - if (dialog != null) { - dialog.force_close (); - dialog = null; - alt_editor = null; - dialog_save_btn = null; - dialog_char_counter = null; + if (focus_picker_dialog != null) { + focus_picker_dialog.force_close (); + focus_picker_dialog = null; } } private void update_alt_css (int text_length) { - if (validate (text_length) && text_length > 0) { + if (AltTextDialog.validate (text_length) && text_length > 0) { alt_btn.add_css_class ("success"); alt_btn.remove_css_class ("error"); } else { diff --git a/src/Dialogs/Composer/Dialog.vala b/src/Dialogs/Composer/Dialog.vala index f15b94a82..07474233f 100644 --- a/src/Dialogs/Composer/Dialog.vala +++ b/src/Dialogs/Composer/Dialog.vala @@ -34,10 +34,15 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { } } + public class MediaEntry { + public string? description = null; + public string? focus = null; + } + public string id { get; set; } public string status { get; set; } public Gee.ArrayList media_ids { get; private set; default=new Gee.ArrayList (); } - public Gee.HashMap media { get; private set; default=new Gee.HashMap (); } + public Gee.HashMap media { get; private set; default=new Gee.HashMap (); } public BasicPoll poll { get; set; } public string in_reply_to_id { get; set; } public bool sensitive { get; set; } @@ -47,9 +52,12 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { public string content_type { get; set; } public Gee.ArrayList? media_attachments { get; set; default = null; } - public void add_media (string t_id, string? t_alt) { + public void add_media (string t_id, string? t_alt, string? t_focus) { media_ids.add (t_id); - media.set (t_id, t_alt ?? ""); + media.set (t_id, new MediaEntry () { + description = t_alt ?? "", + focus = t_focus ?? "" + }); } public void clear_media () { @@ -65,7 +73,13 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { media_attachments = t_status.media_attachments; foreach (var t_attachment in t_status.media_attachments) { - add_media (t_attachment.id, t_attachment.description); + string focus = "0.00,0.00"; + + if (t_attachment.meta != null && t_attachment.meta.focus != null) { + focus = "%.2f,%.2f".printf (t_attachment.meta.focus.x, t_attachment.meta.focus.y); + } + + add_media (t_attachment.id, t_attachment.description, focus); } } @@ -90,7 +104,7 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { && t_status.visibility == visibility && t_status.language == language && array_string_eq (media_ids, t_status.media_ids) - && !alts_changed (t_status) + && !media_meta_changed (t_status) && (poll != null && t_status != null ? poll.equal (t_status.poll) : poll == null && t_status == null); } @@ -107,12 +121,14 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { return res; } - public bool alts_changed (BasicStatus t_status) { + public bool media_meta_changed (BasicStatus t_status) { var res = false; foreach (var entry in this.media.entries) { if (!t_status.media.has_key (entry.key)) continue; - res = t_status.media.get (entry.key) != entry.value; + res = + t_status.media.get (entry.key).description != entry.value.description + || t_status.media.get (entry.key).focus != entry.value.focus; if (res) break; } @@ -383,7 +399,7 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { protected signal void modify_body (Json.Builder builder); - protected virtual void update_alt_texts (Json.Builder builder) { + protected virtual void update_metadata (Json.Builder builder) { if ( status.media_ids.size == 0 || original_status.media_ids.size == 0 @@ -395,14 +411,19 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { foreach (var entry in status.media.entries) { if ( !original_status.media_ids.contains (entry.key) - || original_status.media.get (entry.key) == entry.value + || ( + original_status.media.get (entry.key).description == entry.value.description + && original_status.media.get (entry.key).focus == entry.value.focus + ) ) continue; builder.begin_object (); builder.set_member_name ("id"); builder.add_string_value (entry.key); builder.set_member_name ("description"); - builder.add_string_value (entry.value); + builder.add_string_value (entry.value.description); + builder.set_member_name ("focus"); + builder.add_string_value (entry.value.focus); builder.end_object (); } @@ -414,7 +435,7 @@ public class Tuba.Dialogs.Compose : Adw.Dialog { builder.begin_object (); modify_body (builder); - if (editing) update_alt_texts (builder); + if (editing) update_metadata (builder); if (quote_id != null) { builder.set_member_name ("quote_id"); builder.add_string_value (quote_id); diff --git a/src/Widgets/AudioPlayer/Controls.vala b/src/Widgets/AudioPlayer/Controls.vala index 722056247..e54c90fdb 100644 --- a/src/Widgets/AudioPlayer/Controls.vala +++ b/src/Widgets/AudioPlayer/Controls.vala @@ -77,18 +77,6 @@ public class Tuba.Widgets.Audio.Controls : Gtk.Box { GLib.SignalHandler.unblock (time_adjustment, time_adjustment_changed_id); } - public void update_playing_button (bool playing) { - if (playing) { - play_button.icon_name = "media-playback-pause-symbolic"; - // translators: Media play bar play button tooltip - play_button.tooltip_text = _("Stop"); - } else { - play_button.icon_name = "media-playback-start-symbolic"; - // translators: Media play bar play button tooltip - play_button.tooltip_text = _("Play"); - } - } - ulong time_adjustment_changed_id; construct { volume_button.bind_property ("value", this, "volume", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); diff --git a/src/Widgets/FocusPicker.vala b/src/Widgets/FocusPicker.vala new file mode 100644 index 000000000..5b967b900 --- /dev/null +++ b/src/Widgets/FocusPicker.vala @@ -0,0 +1,191 @@ +// The focuspoint picker +// The API is a bit strange as it's based on https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point +// In summary, they are coordinates, where (1,1) is the top-right corner of the image +// and (-1,-1) is the bottom-left one +// This widget is responsible not only for picking the position on top of the image, +// but also converting the position between the API coordinates and the width-height GTK ones +public class Tuba.Widgets.FocusPicker : Gtk.Widget { + Gtk.Picture pic; + Gtk.Fixed fixed; + Gtk.Image picker; + Gtk.AspectFrame frame; + Gtk.Overlay overlay; + + private double _pos_x = 0.0; + public double pos_x { + get { return _pos_x; } + set { + _pos_x = value; + + update_dot_pos (); + } + } + + private double _pos_y = 0.0; + public double pos_y { + get { return _pos_y; } + set { + _pos_y = value; + + update_dot_pos (); + } + } + + double picker_width_half = 0; + double picker_height_half = 0; + + construct { + overlay = new Gtk.Overlay () { + vexpand = true, + hexpand = true + }; + + picker = new Gtk.Image.from_icon_name ("tuba-radio-checked-symbolic") { + css_classes = { "osd", "focus-picker" }, + valign = Gtk.Align.CENTER, + halign = Gtk.Align.CENTER, + icon_size = Gtk.IconSize.LARGE + }; + + fixed = new Gtk.Fixed (); + overlay.add_overlay (fixed); + overlay.set_clip_overlay (fixed, true); + + var picker_click_gesture = new Gtk.GestureClick () { + button = Gdk.BUTTON_PRIMARY, + propagation_phase = Gtk.PropagationPhase.CAPTURE + }; + picker_click_gesture.pressed.connect (on_pointer_click); + picker_click_gesture.released.connect (on_pointer_click_release); + picker.add_controller (picker_click_gesture); + + var fixed_click_gesture = new Gtk.GestureClick () { + button = Gdk.BUTTON_PRIMARY, + propagation_phase = Gtk.PropagationPhase.CAPTURE + }; + fixed_click_gesture.pressed.connect (on_fixed_click); + fixed.add_controller (fixed_click_gesture); + + var motion = new Gtk.EventControllerMotion (); + motion.motion.connect (on_motion); + fixed.add_controller (motion); + } + + private void on_fixed_click (int n_press, double x, double y) { + if (dragging) return; + + on_motion_real (x, y); + } + + // When the picker is clicked, + // initialize dragging + bool dragging = false; + private void on_pointer_click () { + dragging = true; + } + + // When the picker is released, + // stop dragging + private void on_pointer_click_release () { + dragging = false; + } + + // When the cursor is moving in the fixed widget, + // check if the picker is being dragged and if so, + // move it to the new position + private void on_motion (double x, double y) { + if (!dragging) return; + + on_motion_real (x, y); + } + + // Calculate the new the focuspoint the API accepts + // Width, Height position => API position + private void on_motion_real (double x, double y) { + x = x.clamp (0, pic.get_width ()); + y = y.clamp (0, pic.get_height ()); + + pos_x = (((2 * x) / pic.get_width ()) - 1); + pos_y = (((2 * y) / pic.get_height ()) - 1) * -1; + } + + public FocusPicker (Gdk.Paintable paintable) { + pic = new Gtk.Picture.for_paintable (paintable); + overlay.child = pic; + + frame = new Gtk.AspectFrame ( + 0.5f, + 0.5f, + (float) pic.paintable.get_intrinsic_aspect_ratio (), + false + ) { + child = overlay + }; + frame.set_parent (this); + + // Center the picker + fixed.put (picker, pic.get_width () / 2, pic.get_height () / 2); + } + + public override void size_allocate (int width, int height, int baseline) { + frame.allocate (width, height, baseline, null); + + // Calculate these here once as they're static + if (picker_width_half == 0 || picker_height_half == 0) compute_picker_half (); + + update_dot_pos (); + } + + public override void measure ( + Gtk.Orientation orientation, + int for_size, + out int minimum, + out int natural, + out int minimum_baseline, + out int natural_baseline + ) { + this.frame.measure ( + orientation, + for_size, + out minimum, + out natural, + out minimum_baseline, + out natural_baseline + ); + } + + public override void snapshot (Gtk.Snapshot snapshot) { + base.snapshot (snapshot); + } + + // Moves the picker to the new positions based on + // the focuspoint the API accepts + // API position => Width, Height position + // minus the picker transformations + private void update_dot_pos () { + var new_x = pic.get_width () / 2; + var new_y = pic.get_height () / 2; + + fixed.move ( + picker, + (new_x + new_x * pos_x) - picker_width_half, + (new_y + new_y * pos_y * -1) - picker_height_half + ); + } + + private void compute_picker_half () { + Graphene.Rect rect; + if (!picker.compute_bounds (fixed, out rect)) { + picker_width_half = picker.get_width () / 2; + picker_height_half = picker.get_height () / 2; + } else { + picker_width_half = rect.size.width / 2; + picker_height_half = rect.size.height / 2; + } + } + + ~FocusPicker () { + debug ("Destroying FocusPicker"); + frame.unparent (); + } +} diff --git a/src/Widgets/meson.build b/src/Widgets/meson.build index 798232307..b011216c0 100644 --- a/src/Widgets/meson.build +++ b/src/Widgets/meson.build @@ -8,6 +8,7 @@ sources += files( 'CustomEmojiChooser.vala', 'Emoji.vala', 'EmojiLabel.vala', + 'FocusPicker.vala', 'LabelWithWidgets.vala', 'ListBoxRowWrapper.vala', 'MarkupView.vala',