using Gtk; using Gee; using Gdk; private class TranscriptDrawingArea : Widget { public bool show_sender = true; public Adjustment? viewport { get { return _viewport; } set { _viewport = value; queue_draw(); } } private ArrayList _messages = new ArrayList(); private ArrayList _chat_items = new ArrayList(); private Adjustment? _viewport = null; private const float bubble_margin = 18.0f; private GestureClick _click_gesture = new GestureClick(); private Gdk.Rectangle? _click_bounding_box = null; private const bool debug_viewport = false; private uint? _tick_callback_id = null; private HashMap _animations = new HashMap(); public TranscriptDrawingArea() { add_css_class("transcript-drawing-area"); weak TranscriptDrawingArea self = this; _click_gesture.button = Gdk.BUTTON_SECONDARY; _click_gesture.begin.connect(() => { self.on_right_click(); }); add_controller(_click_gesture); SimpleAction copy_action = new SimpleAction("copy", null); copy_action.activate.connect(() => { if (_click_bounding_box != null) { copy_message_at(_click_bounding_box); } else { GLib.warning("Failed to get bounding box for right click"); } }); SimpleActionGroup action_group = new SimpleActionGroup(); action_group.add_action(copy_action); insert_action_group("transcript", action_group); } public void set_messages(ArrayList 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; _chat_items.foreach((chat_item) => { total_height += chat_item.get_height() + chat_item.vertical_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(Gtk.Snapshot snapshot) { const int viewport_y_padding = 50; var viewport_rect = Graphene.Rect() { origin = Graphene.Point() { x = 0, y = ((int)viewport.value - viewport_y_padding) }, size = Graphene.Size() { width = get_width(), height = (int)viewport.page_size + (viewport_y_padding * 2) } }; if (debug_viewport) { // Draw viewport outline for debugging var color = Gdk.RGBA() { red = 1.0f, green = 0, blue = 0, alpha = 1.0f }; snapshot.append_border( Gsk.RoundedRect().init_from_rect(viewport_rect, 0), { 1, 1, 1, 1 }, { color, color, color, color } ); } // Draw each item in reverse order, since the messages are in reverse order float y_offset = 0; int container_width = get_width(); for (int i = _chat_items.size - 1; i >= 0; i--) { var chat_item = _chat_items[i]; var item_width = chat_item.get_width(); var item_height = chat_item.get_height(); var origin = Graphene.Point() { x = (chat_item.from_me ? (container_width - item_width - bubble_margin) : bubble_margin), y = y_offset }; var size = Graphene.Size() { width = item_width, height = item_height }; var rect = Graphene.Rect() { origin = origin, size = size }; // Skip drawing if this item is not in the viewport float height_offset = 0.0f; if (viewport_rect.intersection(rect, null)) { snapshot.save(); var pushed_opacity = false; if (_animations.has_key(chat_item.id)) { var animation = _animations[chat_item.id]; var item_height_offset = (float) (-item_height * (1.0 - animation.progress)); snapshot.translate(Graphene.Point() { x = 0, y = item_height_offset }); height_offset = item_height_offset; snapshot.push_opacity(animation.progress); pushed_opacity = true; } // Translate to the correct position snapshot.translate(origin); // Flip the y-axis, since our parent is upside down (so newest messages are at the bottom) snapshot.scale(1.0f, -1.0f); // Undo the y-axis flip, origin is top left snapshot.translate(Graphene.Point() { x = 0, y = -item_height }); chat_item.draw(snapshot); if (pushed_opacity) { snapshot.pop(); } snapshot.restore(); } y_offset += item_height + chat_item.vertical_padding + height_offset; } animation_tick(); } private void on_right_click() { var menu_model = new Menu(); menu_model.append("Copy", "transcript.copy"); Gdk.Rectangle? bounding_box = null; if (_click_gesture.get_bounding_box(out bounding_box)) { _click_bounding_box = bounding_box; var menu = new PopoverMenu.from_model(menu_model); menu.set_position(PositionType.TOP); menu.pointing_to = bounding_box; menu.set_parent(this); menu.popup(); } } private void copy_message_at(Gdk.Rectangle bounding_box) { double y_offset = 0.0; for (int i = _chat_items.size - 1; i >= 0; i--) { var chat_item = _chat_items[i]; y_offset += chat_item.get_height() + chat_item.vertical_padding; if (y_offset > bounding_box.y) { chat_item.copy(get_clipboard()); break; } } } private bool animation_tick() { HashSet animations_to_remove = new HashSet(); _animations.foreach(entry => { var animation = entry.value; animation.tick_animation(); if ((animation.progress - 1.0).abs() < 0.000001) { animations_to_remove.add(entry.key); } return true; }); foreach (var key in animations_to_remove) { _animations.unset(key); } if (_animations.size > 0) { queue_draw(); } return _animations.size > 0; } private bool on_frame_clock_tick(Gtk.Widget widget, Gdk.FrameClock frame_clock) { return animation_tick(); } private void start_animation(string id) { if (_animations.has_key(id)) { return; } var animation = new ChatItemAnimation(get_frame_clock()); animation.start_animation(0.18f); _animations.set(id, animation); _tick_callback_id = add_tick_callback(on_frame_clock_tick); } private void recompute_message_layouts() { var container_width = get_width(); float max_width = container_width * 0.90f; DateTime? last_date = null; string? last_sender = null; ArrayList items = new ArrayList(); _messages.foreach((message) => { // Date Annotation DateTime date = message.date; if (last_date != null && date.difference(last_date) > (TimeSpan.HOUR * 1)) { var date_item = new DateItemLayout(date.to_local().format("%b %d, %Y at %H:%M"), this, max_width); items.add(date_item); last_date = date; } // Sender Annotation if (show_sender && !message.from_me && last_sender != message.sender) { var sender_bubble = new SenderAnnotationLayout(message.sender, max_width, this); items.add(sender_bubble); } // Text Bubble var animate = message.should_animate; if (message.text.length > 0 && !message.is_attachment_marker) { var text_bubble = new TextBubbleLayout(message, this, max_width); text_bubble.id = @"text-$(message.guid)"; if (animate) { start_animation(text_bubble.id); } text_bubble.vertical_padding = (last_sender == message.sender) ? 4.0f : 10.0f; items.add(text_bubble); } // Check for attachments. For each one, add an image layout bubble foreach (var attachment in message.attachments) { Graphene.Size? image_size = null; if (attachment.metadata != null) { image_size = Graphene.Size() { width = attachment.metadata.attribution_info.width, height = attachment.metadata.attribution_info.height }; } var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size); image_layout.id = @"image-$(attachment.guid)"; if (animate) { start_animation(image_layout.id); } image_layout.is_downloaded = attachment.preview_downloaded; items.add(image_layout); // If the attachment isn't downloaded, queue a download since we are going to be showing it here. // TODO: Probably would be better if we only did this for stuff in the viewport. if (!attachment.preview_downloaded) { try { Repository.get_instance().download_attachment(attachment.guid, true); } catch (GLib.Error e) { warning("Wasn't able to message daemon about queuing attachment download: %s", e.message); } } } last_sender = message.sender; last_date = date; return true; }); _chat_items.clear(); _chat_items.add_all(items); queue_draw(); queue_resize(); } } private class ChatItemAnimation { public double progress = 0.0; private int64 start_time = 0; private double duration = 0.0; private Gdk.FrameClock frame_clock = null; delegate double EaseFunction(double t); private EaseFunction ease_function = ease_out_quart; public ChatItemAnimation(Gdk.FrameClock frame_clock) { this.frame_clock = frame_clock; progress = 0.0f; } public void start_animation(double duration) { this.duration = duration; this.progress = 0.0; this.start_time = frame_clock.get_frame_time(); } public void tick_animation() { double t = (frame_clock.get_frame_time() - start_time) / (duration * 1000000.0); t = double.min(1.0, double.max(0.0, t)); progress = ease_function(t); } private static double ease_out_quart(double t) { return 1.0 - Math.pow(1.0 - t, 4); } }