diff --git a/src/resources/style.css b/src/resources/style.css index 9c29d8f..37c73bf 100644 --- a/src/resources/style.css +++ b/src/resources/style.css @@ -9,7 +9,7 @@ background-color: alpha(@accent_bg_color, 0.50); } -.message-list-scroller { +.flipped-y-axis { /* Invert the y-axis, so the messages are drawn bottom-to-top */ /* Individual messages are drawn upside down in the custom renderer */ transform: scale(1, -1); @@ -51,3 +51,9 @@ .attachment-image { border-radius: 8px; } + +.hovering-text-view { + background-color: transparent; + color: transparent; + line-height: 1.18; /* TextBubbleLayout.line_height */ +} diff --git a/src/transcript/layouts/bubble-layout.vala b/src/transcript/layouts/bubble-layout.vala index dbda1a6..1251a58 100644 --- a/src/transcript/layouts/bubble-layout.vala +++ b/src/transcript/layouts/bubble-layout.vala @@ -1,6 +1,6 @@ using Gtk; -private struct BubbleLayoutConstants { +public struct BubbleLayoutConstants { public float tail_width; public float tail_curve_offset; public float tail_side_offset; @@ -23,7 +23,7 @@ private struct BubbleLayoutConstants { } } -private abstract class BubbleLayout : Object, ChatItemLayout +public abstract class BubbleLayout : Object, ChatItemLayout { public bool from_me { get; set; } public float vertical_padding { get; set; } diff --git a/src/transcript/layouts/chat-item-layout.vala b/src/transcript/layouts/chat-item-layout.vala index 40c5f1e..bfbf712 100644 --- a/src/transcript/layouts/chat-item-layout.vala +++ b/src/transcript/layouts/chat-item-layout.vala @@ -1,6 +1,6 @@ using Gtk; -interface ChatItemLayout : Object +public interface ChatItemLayout : Object { public abstract bool from_me { get; set; } public abstract float vertical_padding { get; set; } diff --git a/src/transcript/layouts/text-bubble-layout.vala b/src/transcript/layouts/text-bubble-layout.vala index 4b3fce2..103b920 100644 --- a/src/transcript/layouts/text-bubble-layout.vala +++ b/src/transcript/layouts/text-bubble-layout.vala @@ -1,10 +1,23 @@ using Gtk; -private class TextBubbleLayout : BubbleLayout +public class TextBubbleLayout : BubbleLayout { public Message message; private Pango.Layout layout; + public static float line_height { get { return 1.18f; } } + + public static Pango.FontDescription body_font { + owned get { + var settings = Gtk.Settings.get_default(); + var font_name = settings.gtk_font_name; + + // Create font description from system font + var font_desc = Pango.FontDescription.from_string(font_name); + return font_desc; + } + } + public TextBubbleLayout(Message message, Widget parent, float max_width) { base(parent, max_width); @@ -13,15 +26,10 @@ private class TextBubbleLayout : BubbleLayout layout = parent.create_pango_layout(message.text); - // Get the system font settings - var settings = Gtk.Settings.get_default(); - var font_name = settings.gtk_font_name; - - // Create font description from system font - var font_desc = Pango.FontDescription.from_string(font_name); + var font_desc = TextBubbleLayout.body_font; layout.set_font_description(font_desc); layout.set_wrap(Pango.WrapMode.WORD_CHAR); - layout.set_line_spacing(1.18f); + layout.set_line_spacing(line_height); // Set max width layout.set_width((int)text_available_width * Pango.SCALE); @@ -71,16 +79,20 @@ private class TextBubbleLayout : BubbleLayout return logical_rect.width + text_x_offset + text_x_padding; } + public Graphene.Point get_text_origin() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return Graphene.Point() { + x = text_x_offset, + y = ((get_height() - constants.tail_bottom_padding) - logical_rect.height) / 2 + }; + } + public override void draw_content(Snapshot snapshot) { snapshot.save(); - Pango.Rectangle ink_rect, logical_rect; - layout.get_pixel_extents(out ink_rect, out logical_rect); - - snapshot.translate(Graphene.Point() { - x = text_x_offset, - y = ((get_height() - constants.tail_bottom_padding) - logical_rect.height) / 2 - }); + snapshot.translate(get_text_origin()); snapshot.append_layout(layout, Gdk.RGBA() { red = 1.0f, diff --git a/src/transcript/transcript-drawing-area.vala b/src/transcript/transcript-drawing-area.vala index 52a84e8..90edda3 100644 --- a/src/transcript/transcript-drawing-area.vala +++ b/src/transcript/transcript-drawing-area.vala @@ -16,6 +16,9 @@ private class TranscriptDrawingArea : Widget } } + public signal void on_text_bubble_hover(VisibleTextLayout? text_bubble); + public signal void on_text_bubble_click(VisibleTextLayout? text_bubble); + private ArrayList _messages = new ArrayList(); private ArrayList _chat_items = new ArrayList(); @@ -26,6 +29,7 @@ private class TranscriptDrawingArea : Widget private Gdk.Rectangle? _click_bounding_box = null; private EventControllerMotion _motion_controller = new EventControllerMotion(); + private ArrayList _visible_text_layouts = new ArrayList(); private const bool debug_viewport = false; private uint? _tick_callback_id = null; @@ -36,9 +40,9 @@ private class TranscriptDrawingArea : Widget weak TranscriptDrawingArea self = this; - _click_gesture.button = Gdk.BUTTON_SECONDARY; + _click_gesture.button = Gdk.BUTTON_SECONDARY | Gdk.BUTTON_PRIMARY; _click_gesture.begin.connect(() => { - self.on_right_click(); + self.on_click(self._click_gesture.get_button()); }); add_controller(_click_gesture); @@ -129,6 +133,7 @@ private class TranscriptDrawingArea : Widget // Draw each item in reverse order, since the messages are in reverse order float y_offset = 0; int container_width = get_width(); + _visible_text_layouts.clear(); for (int i = _chat_items.size - 1; i >= 0; i--) { var chat_item = _chat_items[i]; var item_width = chat_item.get_width(); @@ -152,6 +157,10 @@ private class TranscriptDrawingArea : Widget // Skip drawing if this item is not in the viewport float height_offset = 0.0f; if (viewport_rect.intersection(rect, null)) { + if (chat_item is TextBubbleLayout) { + _visible_text_layouts.add(VisibleTextLayout(chat_item as TextBubbleLayout, rect)); + } + snapshot.save(); var pushed_opacity = false; @@ -190,8 +199,35 @@ private class TranscriptDrawingArea : Widget animation_tick(); } + private VisibleTextLayout? get_text_bubble_at(double x, double y) { + foreach (var layout in _visible_text_layouts) { + if (layout.rect.contains_point(Graphene.Point() { x = (float)x, y = (float)y })) { + return layout; + } + } + + return null; + } + private void on_mouse_motion(double x, double y) { - // TODO: Will be making temporary text views here. + VisibleTextLayout? hovered_text_bubble = get_text_bubble_at(x, y); + on_text_bubble_hover(hovered_text_bubble); + } + + private void on_click(uint button) { + if (button == Gdk.BUTTON_SECONDARY) { + on_right_click(); + } else if (button == Gdk.BUTTON_PRIMARY) { + on_left_click(); + } + } + + private void on_left_click() { + Gdk.Rectangle? bounding_box = null; + if (_click_gesture.get_bounding_box(out bounding_box)) { + var text_bubble = get_text_bubble_at(bounding_box.x, bounding_box.y); + on_text_bubble_click(text_bubble); + } } private void on_right_click() { @@ -372,4 +408,14 @@ private class ChatItemAnimation private static double ease_out_quart(double t) { return 1.0 - Math.pow(1.0 - t, 4); } +} + +public struct VisibleTextLayout { + public weak TextBubbleLayout text_bubble; + public Graphene.Rect rect; + + public VisibleTextLayout(TextBubbleLayout text_bubble, Graphene.Rect rect) { + this.text_bubble = text_bubble; + this.rect = rect; + } } \ No newline at end of file diff --git a/src/transcript/transcript-view.vala b/src/transcript/transcript-view.vala index a081bb2..6da5ffc 100644 --- a/src/transcript/transcript-view.vala +++ b/src/transcript/transcript-view.vala @@ -17,10 +17,7 @@ public class TranscriptView : Adw.Bin _model = value; if (value != null) { - // Reset scroll position by updating the existing adjustment - scrolled_window.vadjustment.value = 0; - scrolled_window.vadjustment.upper = 0; - scrolled_window.vadjustment.page_size = 0; + reset_for_conversation_change(); weak TranscriptView self = this; messages_changed_handler_id = value.messages_changed.connect(() => { @@ -44,11 +41,18 @@ public class TranscriptView : Adw.Bin private Adw.ToolbarView container; private Label title_label = new Label("Messages"); + private Overlay overlay = new Overlay(); private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea(); private ScrolledWindow scrolled_window = new ScrolledWindow(); private ulong messages_changed_handler_id = 0; private bool needs_reload = false; + private Graphene.Point _hovering_text_view_origin = Graphene.Point() { x = 0, y = 0 }; + private TextView _hovering_text_view = new TextView(); + + private VisibleTextLayout? hovered_text_bubble = null; + private VisibleTextLayout? locked_text_bubble = null; + public TranscriptView() { container = new Adw.ToolbarView(); set_child(container); @@ -56,8 +60,26 @@ public class TranscriptView : Adw.Bin // Set minimum width for the transcript view set_size_request(330, -1); - scrolled_window.set_child(transcript_drawing_area); - scrolled_window.add_css_class("message-list-scroller"); + overlay.set_child(transcript_drawing_area); + + weak TranscriptView self = this; + overlay.get_child_position.connect((child, out allocation) => { + allocation = Gtk.Allocation() { x = 0, y = 0, width = 0, height = 0 }; + if (self.hovered_text_bubble != null) { + var rect = self.hovered_text_bubble.rect; + allocation.x = (int)(rect.origin.x + self._hovering_text_view_origin.x); + allocation.y = (int)(rect.origin.y - self._hovering_text_view_origin.y); + allocation.width = (int)rect.size.width; + allocation.height = (int)rect.size.height; + + return true; + } + + return true; + }); + + scrolled_window.set_child(overlay); + scrolled_window.add_css_class("flipped-y-axis"); transcript_drawing_area.viewport = scrolled_window.vadjustment; container.set_content(scrolled_window); @@ -72,6 +94,33 @@ public class TranscriptView : Adw.Bin header_bar.set_title_widget(title_label); container.add_top_bar(header_bar); + // This is an invisible text view that's used to handle selection. + _hovering_text_view.add_css_class("hovering-text-view"); + _hovering_text_view.add_css_class("flipped-y-axis"); + _hovering_text_view.set_editable(false); + _hovering_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR); + overlay.add_overlay(_hovering_text_view); + + // When the selection changes, lock the text bubble so that if the cursor moves to another bubble we don't clear it. + _hovering_text_view.buffer.mark_set.connect((location, end) => { + self.lock_text_bubble(self.hovered_text_bubble); + }); + + // When the mouse hovers over a text bubble, configure the hovering text view to show the text of the bubble. + transcript_drawing_area.on_text_bubble_hover.connect((visible_text_layout) => { + if (visible_text_layout != null) { + configure_hovering_text_view(visible_text_layout); + } + }); + + // This is triggered when another bubble is currently locked, and the user clicks on a new bubble. + transcript_drawing_area.on_text_bubble_click.connect((visible_text_layout) => { + if (visible_text_layout != null) { + locked_text_bubble = null; + configure_hovering_text_view(visible_text_layout); + } + }); + Repository.get_instance().attachment_downloaded.connect((attachment_guid) => { debug("Attachment downloaded: %s", attachment_guid); @@ -103,6 +152,35 @@ public class TranscriptView : Adw.Bin }); } + private void reset_for_conversation_change() { + locked_text_bubble = null; + hovered_text_bubble = null; + _hovering_text_view.buffer.text = ""; + overlay.queue_allocate(); + + // Reset scroll position by updating the existing adjustment + scrolled_window.vadjustment.value = 0; + scrolled_window.vadjustment.upper = 0; + scrolled_window.vadjustment.page_size = 0; + } + + private void lock_text_bubble(VisibleTextLayout? visible_text_layout) { + if (visible_text_layout != null) { + locked_text_bubble = visible_text_layout; + configure_hovering_text_view(visible_text_layout); + } + } + + private void configure_hovering_text_view(VisibleTextLayout? visible_text_layout) { + hovered_text_bubble = visible_text_layout; + + if (locked_text_bubble == null) { + _hovering_text_view_origin = visible_text_layout.text_bubble.get_text_origin(); + _hovering_text_view.buffer.text = visible_text_layout.text_bubble.message.text; + overlay.queue_allocate(); + } + } + private void reload_messages() { transcript_drawing_area.show_sender = _model.is_group_chat; transcript_drawing_area.set_messages(_model.messages);