Private
Public Access
1
0

implement bubble view

This commit is contained in:
2025-04-30 19:12:00 -07:00
parent e976b3db4c
commit 4c7c31ab8d
6 changed files with 368 additions and 51 deletions

View File

@@ -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',

View File

@@ -0,0 +1,96 @@
using Gtk;
using Gee;
private class MessageDrawingArea : Widget
{
private SortedSet<Message> _messages = new TreeSet<Message>();
private ArrayList<MessageLayout> _message_layouts = new ArrayList<MessageLayout>();
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<Message> 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();
}
}

View File

@@ -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<Snapshot> 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();
}
}

View File

@@ -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<Message>();
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 });
}
}

View File

@@ -7,6 +7,12 @@ public class Message : Object
public int64 date { get; set; default = 0; }
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<string, Variant> message_data) {
guid = message_data["guid"].get_string();
content = message_data["content"].get_string();

View File

@@ -14,3 +14,7 @@
/* Individual messages are drawn upside down in the custom renderer */
transform: scale(1, -1);
}
.message-drawing-area {
color: darker(@accent_bg_color);
}