Private
Public Access
1
0

Enables selection of bubbles using an invisible text view

This commit is contained in:
2025-06-18 16:50:14 -07:00
parent 3b6666cfc2
commit ccfea2883c
6 changed files with 170 additions and 28 deletions

View File

@@ -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 */
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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 override void draw_content(Snapshot snapshot) {
snapshot.save();
public Graphene.Point get_text_origin() {
Pango.Rectangle ink_rect, logical_rect;
layout.get_pixel_extents(out ink_rect, out logical_rect);
snapshot.translate(Graphene.Point() {
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();
snapshot.translate(get_text_origin());
snapshot.append_layout(layout, Gdk.RGBA() {
red = 1.0f,

View File

@@ -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<Message> _messages = new ArrayList<Message>();
private ArrayList<ChatItemLayout> _chat_items = new ArrayList<ChatItemLayout>();
@@ -26,6 +29,7 @@ private class TranscriptDrawingArea : Widget
private Gdk.Rectangle? _click_bounding_box = null;
private EventControllerMotion _motion_controller = new EventControllerMotion();
private ArrayList<VisibleTextLayout?> _visible_text_layouts = new ArrayList<VisibleTextLayout?>();
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() {
@@ -373,3 +409,13 @@ private class ChatItemAnimation
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;
}
}

View File

@@ -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);