From 4c7c31ab8d087e2f22f6a0abc64f15b904c5c5a3 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 30 Apr 2025 19:12:00 -0700 Subject: [PATCH] implement bubble view --- src/meson.build | 2 + src/message-list/message-drawing-area.vala | 96 ++++++++ src/message-list/message-layout.vala | 249 +++++++++++++++++++++ src/message-list/message-list-view.vala | 60 +---- src/models/message.vala | 8 +- src/resources/style.css | 4 + 6 files changed, 368 insertions(+), 51 deletions(-) create mode 100644 src/message-list/message-drawing-area.vala create mode 100644 src/message-list/message-layout.vala diff --git a/src/meson.build b/src/meson.build index a6d9545..bfede65 100644 --- a/src/meson.build +++ b/src/meson.build @@ -26,6 +26,8 @@ sources = [ 'message-list/message-list-view.vala', 'message-list/message-list-model.vala', + 'message-list/message-drawing-area.vala', + 'message-list/message-layout.vala', 'models/conversation.vala', 'models/message.vala', diff --git a/src/message-list/message-drawing-area.vala b/src/message-list/message-drawing-area.vala new file mode 100644 index 0000000..0fa74f2 --- /dev/null +++ b/src/message-list/message-drawing-area.vala @@ -0,0 +1,96 @@ +using Gtk; +using Gee; + +private class MessageDrawingArea : Widget +{ + private SortedSet _messages = new TreeSet(); + private ArrayList _message_layouts = new ArrayList(); + + private const float bubble_padding = 10.0f; + private const float bubble_margin = 18.0f; + + public MessageDrawingArea() { + add_css_class("message-drawing-area"); + } + + public void set_messages(SortedSet messages) { + _messages = messages; + recompute_message_layouts(); + } + + public override SizeRequestMode get_request_mode() { + return SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) + { + if (orientation == Orientation.HORIZONTAL) { + // Horizontal, so we take up the full width provided + minimum = 0; + natural = for_size; + } else { + // compute total message layout height + float total_height = 0.0f; + _message_layouts.foreach((message_layout) => { + total_height += message_layout.get_height() + bubble_padding; + return true; + }); + + minimum = (int)total_height; + natural = (int)total_height; + } + + minimum_baseline = -1; + natural_baseline = -1; + } + + public override void size_allocate(int width, int height, int baseline) { + base.size_allocate(width, height, baseline); + recompute_message_layouts(); + } + + public override void snapshot(Snapshot snapshot) { + var container_width = get_width(); + float y_offset = 0; + _message_layouts.foreach((message_layout) => { + var message_width = message_layout.get_width(); + var message_height = message_layout.get_height(); + + snapshot.save(); + + // Flip the y-axis, since our parent is upside down (so newest messages are at the bottom) + snapshot.scale(1.0f, -1.0f); + + // Translate to the correct position + snapshot.translate(Graphene.Point() { + x = (message_layout.from_me ? (container_width - message_width - bubble_margin) : bubble_margin), + y = y_offset + }); + + // Undo the y-axis flip, origin is top left + snapshot.translate(Graphene.Point() { x = 0, y = -message_height }); + + message_layout.draw(snapshot); + snapshot.restore(); + + y_offset -= message_height + bubble_padding; + + return true; + }); + } + + private void recompute_message_layouts() { + var container_width = get_width(); + float max_width = container_width * 0.90f; + + _message_layouts.clear(); + _messages + .order_by((a, b) => (int)b.date - (int)a.date) // reverse order + .foreach((message) => { + _message_layouts.add(new MessageLayout(message, this, max_width)); + return true; + }); + + queue_draw(); + } +} \ No newline at end of file diff --git a/src/message-list/message-layout.vala b/src/message-list/message-layout.vala new file mode 100644 index 0000000..8135a96 --- /dev/null +++ b/src/message-list/message-layout.vala @@ -0,0 +1,249 @@ +using Gtk; +using Gee; + +private class MessageLayout : Object +{ + public Message message; + + private float max_width; + private Pango.Layout layout; + private Widget parent; + + const float tail_width = 15.0f; + const float tail_curve_offset = 2.5f; + const float tail_side_offset = 0.0f; + const float tail_bottom_padding = 4.0f; + const float corner_radius = 32.0f; + const float text_padding = 18.0f; + + const string font_description = "Sans 13"; + + public MessageLayout(Message message, Widget parent, float max_width) { + this.message = message; + this.max_width = max_width; + this.parent = parent; + + layout = parent.create_pango_layout(message.content); + + // Set text attributes + var font_desc = Pango.FontDescription.from_string(font_description); + layout.set_font_description(font_desc); + + // Set max width + layout.set_width((int)text_available_width * Pango.SCALE); + } + + public bool from_me { + get { + return message.sender == null; + } + } + + private float text_available_width { + get { + return max_width - text_x_offset - text_padding; + } + } + + private float text_x_offset { + get { + return from_me ? text_padding : tail_width + text_padding; + } + } + + private float text_x_padding { + get { + // Opposite of text_x_offset + return from_me ? tail_width + text_padding : text_padding; + } + } + + private Gdk.RGBA background_color { + get { + return from_me ? parent.get_color() : Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 0.08f + }; + } + } + + public float get_height() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.height + corner_radius + tail_bottom_padding; + } + + public float get_width() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.width + text_x_offset + text_x_padding; + } + + public void draw(Snapshot snapshot) { + with_bubble_clip(snapshot, (snapshot) => { + draw_background(snapshot); + draw_text(snapshot); + }); + } + + private void draw_text(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() - tail_bottom_padding) - logical_rect.height) / 2 + }); + + snapshot.append_layout(layout, Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 1.0f + }); + + snapshot.restore(); + } + + private void draw_background(Snapshot snapshot) + { + snapshot.save(); + + var width = get_width(); + var height = get_height(); + var color = background_color; + var bounds = Graphene.Rect().init(0, 0, width, height); + + const float gradient_darkening = 0.67f; + var start_point = Graphene.Point() { x = 0, y = 0 }; + var end_point = Graphene.Point() { x = 0, y = height }; + var stops = new Gsk.ColorStop[] { + Gsk.ColorStop() { offset = 0.0f, color = color }, + Gsk.ColorStop() { offset = 1.0f, color = Gdk.RGBA () { + red = color.red * gradient_darkening, + green = color.green * gradient_darkening, + blue = color.blue * gradient_darkening, + alpha = color.alpha + } }, + }; + + snapshot.append_linear_gradient(bounds, start_point, end_point, stops); + + snapshot.restore(); + } + + private void with_bubble_clip(Snapshot snapshot, Func func) { + var width = get_width(); + var height = get_height(); + var path = create_bubble_path(width, height, from_me); + snapshot.push_fill(path, Gsk.FillRule.WINDING); + func(snapshot); + snapshot.pop(); + } + + private Gsk.Path create_bubble_path(float width, float height, bool tail_on_right = false) + { + var builder = new Gsk.PathBuilder(); + + float bubble_width = width - tail_width; + float bubble_height = height - tail_bottom_padding; + + // Base position adjustments based on tail position + float x = tail_on_right ? 0.0f : tail_width; + float y = 0.0f; + + // Calculate tail direction multiplier (-1 for left, 1 for right) + float dir = tail_on_right ? 1.0f : -1.0f; + + // Calculate tail side positions based on direction + float tail_side_x = tail_on_right ? (x + bubble_width) : x; + + // Start at top corner opposite to the tail + builder.move_to(tail_on_right ? (x + corner_radius) : (x + bubble_width - corner_radius), y); + + // Top edge + builder.line_to(tail_on_right ? (x + bubble_width - corner_radius) : (x + corner_radius), y); + + // Top corner on tail side + if (tail_on_right) { + builder.html_arc_to(x + bubble_width, y, + x + bubble_width, y + corner_radius, + corner_radius); + } else { + builder.html_arc_to(x, y, + x, y + corner_radius, + corner_radius); + } + + // Side edge on tail side + builder.line_to(tail_side_x, y + bubble_height - corner_radius); + + // Corner with tail + float tail_tip_x = tail_side_x + (dir * (tail_width - tail_curve_offset)); + float tail_tip_y = y + bubble_height; + + // Control points for the bezier curve + float ctrl_point1_x = tail_side_x + (dir * tail_side_offset); + float ctrl_point1_y = y + bubble_height - corner_radius/3; + + float ctrl_point2_x = tail_side_x + (dir * (tail_width / 2.0f)); + float ctrl_point2_y = y + bubble_height - 2; + + // Point where the tail meets the bottom edge + float tail_base_x = tail_side_x - (dir * corner_radius/2); + float tail_base_y = y + bubble_height; + + // Draw the corner with tail using bezier curves + builder.cubic_to(ctrl_point1_x, ctrl_point1_y, + ctrl_point2_x, ctrl_point2_y, + tail_tip_x, tail_tip_y); + + builder.cubic_to(tail_tip_x - (dir * tail_curve_offset), tail_tip_y + tail_curve_offset, + tail_base_x + (dir * tail_width), tail_base_y, + tail_base_x, tail_base_y); + + // Bottom edge + builder.line_to(tail_on_right ? (x + corner_radius) : (x + bubble_width - corner_radius), y + bubble_height); + + // Bottom corner opposite to tail + if (tail_on_right) { + builder.html_arc_to(x, y + bubble_height, + x, y + bubble_height - corner_radius, + corner_radius); + } else { + builder.html_arc_to(x + bubble_width, y + bubble_height, + x + bubble_width, y + bubble_height - corner_radius, + corner_radius); + } + + // Side edge opposite to tail + if (tail_on_right) { + builder.line_to(x, y + corner_radius); + + // Top corner to close path + builder.html_arc_to(x, y, + x + corner_radius, y, + corner_radius); + } else { + builder.line_to(x + bubble_width, y + corner_radius); + + // Top corner to close path + builder.html_arc_to(x + bubble_width, y, + x + bubble_width - corner_radius, y, + corner_radius); + } + + // Close the path + builder.close(); + + return builder.to_path(); + } +} + + diff --git a/src/message-list/message-list-view.vala b/src/message-list/message-list-view.vala index 508e94a..4ea9b46 100644 --- a/src/message-list/message-list-view.vala +++ b/src/message-list/message-list-view.vala @@ -1,5 +1,6 @@ using Adw; using Gtk; +using Gee; public class MessageListView : Adw.Bin { @@ -19,55 +20,14 @@ public class MessageListView : Adw.Bin var header_bar = new Adw.HeaderBar(); header_bar.set_title_widget(new Label("Messages")); container.add_top_bar(header_bar); + + // Create test message set + var messages = new TreeSet(); + messages.add(new Message("Hello, world!", 1, "user")); + messages.add(new Message("How, are you?", 2, null)); + messages.add(new Message("I'm fine, thank you!", 3, "user")); + messages.add(new Message("GTK also supports color expressions, which allow colors to be transformed to new ones and can be nested, providing a rich language to define colors. Color expressions resemble functions, taking 1 or more colors and in some cases a number as arguments.", 4, "user")); + + message_drawing_area.set_messages(messages); } } - - -private class MessageDrawingArea : Widget { - public MessageDrawingArea() { - } - - public override SizeRequestMode get_request_mode() { - return SizeRequestMode.HEIGHT_FOR_WIDTH; - } - - public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) { - GLib.message("Measure orientation: %s, for_size: %d", orientation.to_string(), for_size); - - if (orientation == Orientation.HORIZONTAL) { - // Horizontal, so we take up the full width provided - minimum = 0; - natural = for_size; - } else { - GLib.message("Vertical measure for width: %d", for_size); - minimum = 1500; - natural = 1500; - } - - minimum_baseline = -1; - natural_baseline = -1; - } - - public override void snapshot(Snapshot snapshot) { - var width = get_width(); - var height = get_height(); - - GLib.message("Snapshot width: %d, height: %d", width, height); - - var rect = Graphene.Rect().init(0, 0, width, height); - snapshot.append_color({1.0f, 0.0f, 0.0f, 1.0f}, rect); - - // Create a text layout - var layout = create_pango_layout("Hello World!"); - layout.set_width(width * Pango.SCALE); - - // Set text attributes - var font_desc = Pango.FontDescription.from_string("Sans 14"); - layout.set_font_description(font_desc); - - // Draw the text in white - snapshot.append_layout(layout, Gdk.RGBA() { red = 1.0f, green = 1.0f, blue = 1.0f, alpha = 1.0f }); - } -} - - diff --git a/src/models/message.vala b/src/models/message.vala index 66e5276..b7fc7c9 100644 --- a/src/models/message.vala +++ b/src/models/message.vala @@ -5,7 +5,13 @@ public class Message : Object public string guid { get; set; default = ""; } public string content { get; set; default = ""; } public int64 date { get; set; default = 0; } - public string?sender { get; set; default = null; } + public string? sender { get; set; default = null; } + + public Message(string content, int64 date, string? sender) { + this.content = content; + this.date = date; + this.sender = sender; + } public Message.from_hash_table(HashTable message_data) { guid = message_data["guid"].get_string(); diff --git a/src/resources/style.css b/src/resources/style.css index cbe637e..bc9a39f 100644 --- a/src/resources/style.css +++ b/src/resources/style.css @@ -13,4 +13,8 @@ /* 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); +} + +.message-drawing-area { + color: darker(@accent_bg_color); } \ No newline at end of file