From 6fb88c3a0daabf3a1bf3181a9c65cce853d12151 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 12 Jun 2025 20:35:56 -0700 Subject: [PATCH] Switch from Entry to TextView for multiline, paste support for attachments --- src/application/kordophone-application.vala | 3 + src/models/message.vala | 11 ++ src/resources/style.css | 4 +- .../layouts/image-bubble-layout.vala | 56 ++++++++- src/transcript/transcript-container-view.vala | 116 +++++++++++++++--- src/transcript/transcript-drawing-area.vala | 8 +- src/transcript/transcript-view.vala | 3 + 7 files changed, 179 insertions(+), 22 deletions(-) diff --git a/src/application/kordophone-application.vala b/src/application/kordophone-application.vala index 3260af4..942bcb2 100644 --- a/src/application/kordophone-application.vala +++ b/src/application/kordophone-application.vala @@ -12,6 +12,9 @@ public class KordophoneApp : Adw.Application protected override void startup () { base.startup (); + // Set default icon theme + Gtk.Settings.get_default().set_property("gtk-icon-theme-name", "Adwaita"); + // Load CSS from resources var provider = new Gtk.CssProvider (); provider.load_from_resource ("/net/buzzert/kordophone2/style.css"); diff --git a/src/models/message.vala b/src/models/message.vala index 8590e63..7fa7886 100644 --- a/src/models/message.vala +++ b/src/models/message.vala @@ -16,6 +16,17 @@ public class Message : Object } } + public bool is_attachment_marker { + get { + uint8[] attachment_marker_str = { 0xEF, 0xBF, 0xBC, 0x00 }; + if (text.length > attachment_marker_str.length) { + return false; + } + + return (string)attachment_marker_str == text; + } + } + public Message(string text, DateTime date, string? sender) { this.text = text; this.date = date; diff --git a/src/resources/style.css b/src/resources/style.css index 5b04e69..9c29d8f 100644 --- a/src/resources/style.css +++ b/src/resources/style.css @@ -28,6 +28,9 @@ .message-input-entry { font-size: 1.1rem; + border-radius: 8px; + padding: 12px; + border: 1px solid alpha(@borders, 0.5); } .attachment-preview-row { @@ -38,7 +41,6 @@ .attachment-preview { border-radius: 8px; - overflow: hidden; border: 1px solid alpha(@borders, 0.5); } diff --git a/src/transcript/layouts/image-bubble-layout.vala b/src/transcript/layouts/image-bubble-layout.vala index 2e25641..ea3feea 100644 --- a/src/transcript/layouts/image-bubble-layout.vala +++ b/src/transcript/layouts/image-bubble-layout.vala @@ -1,5 +1,27 @@ +using Gee; using Gtk; +private class SizeCache +{ + private static SizeCache instance = null; + private HashMap size_cache = new HashMap(); + + public static SizeCache get_instance() { + if (instance == null) { + instance = new SizeCache(); + } + return instance; + } + + public Graphene.Size? get_size(string image_path) { + return size_cache.get(image_path); + } + + public void set_size(string image_path, Graphene.Size size) { + size_cache.set(image_path, size); + } +} + private class ImageBubbleLayout : BubbleLayout { public string image_path; @@ -25,6 +47,12 @@ private class ImageBubbleLayout : BubbleLayout return; } + var cached_size = SizeCache.get_instance().get_size(image_path); + if (cached_size != null) { + this.image_size = cached_size; + return; + } + // Try to load the image to get its dimensions try { warning("No image size provided, loading image to get dimensions"); @@ -34,6 +62,7 @@ private class ImageBubbleLayout : BubbleLayout var original_height = (float)texture.get_height(); this.image_size = Graphene.Size() { width = original_width, height = original_height }; + SizeCache.get_instance().set_size(image_path, this.image_size); } catch (Error e) { // Fallback dimensions if image can't be loaded warning("Failed to load image %s: %s", image_path, e.message); @@ -57,13 +86,26 @@ private class ImageBubbleLayout : BubbleLayout } } + private float intrinsic_height { + get { + var scale_factor = float.min(max_width / image_size.width, 1.0f); + return image_size.height * scale_factor; + } + } + + private float intrinsic_width { + get { + var scale_factor = float.min(max_width / image_size.width, 1.0f); + return image_size.width * scale_factor; + } + } + public override float get_height() { - var scale_factor = float.min(max_width / image_size.width, 1.0f); - return image_size.height * scale_factor; + return float.max(intrinsic_height, 100.0f); } public override float get_width() { - return float.min(image_size.width, max_width); + return float.max(intrinsic_width, 200.0f); } public override void draw_content(Snapshot snapshot) { @@ -73,9 +115,15 @@ private class ImageBubbleLayout : BubbleLayout var image_rect = Graphene.Rect () { origin = Graphene.Point() { x = 0, y = 0 }, - size = Graphene.Size() { width = get_width(), height = get_height() } + size = Graphene.Size() { width = intrinsic_width, height = intrinsic_height } }; + // Center image in the bubble (if it's smaller than the bubble) + snapshot.translate(Graphene.Point() { + x = (get_width() - intrinsic_width) / 2, + y = (get_height() - intrinsic_height) / 2 + }); + if (cached_texture != null) { snapshot.append_texture(cached_texture, image_rect); } else { diff --git a/src/transcript/transcript-container-view.vala b/src/transcript/transcript-container-view.vala index 4da4f65..1253122 100644 --- a/src/transcript/transcript-container-view.vala +++ b/src/transcript/transcript-container-view.vala @@ -1,6 +1,8 @@ using Gtk; using Adw; using Gee; +using Gdk; +using GLib; class TranscriptContainerView : Adw.Bin { @@ -11,14 +13,17 @@ class TranscriptContainerView : Adw.Bin private Button send_button; private FlowBox attachment_flow_box; - private Entry message_entry; + private TextView message_view; + private TextBuffer message_buffer; private HashSet pending_uploads; private HashMap attachment_previews; private ArrayList completed_attachments; public string message_body { - get { - return message_entry.text; + owned get { + TextIter start_iter, end_iter; + message_buffer.get_bounds(out start_iter, out end_iter); + return message_buffer.get_text(start_iter, end_iter, false); } } @@ -36,7 +41,7 @@ class TranscriptContainerView : Adw.Bin private bool can_send { get { - return (message_entry.text.length > 0 || completed_attachments.size > 0) && pending_uploads.size == 0; + return (message_body.length > 0 || completed_attachments.size > 0) && pending_uploads.size == 0; } } @@ -69,14 +74,38 @@ class TranscriptContainerView : Adw.Bin // Connect to repository signals Repository.get_instance().attachment_uploaded.connect(on_attachment_uploaded); - // Create message entry - message_entry = new Entry(); - message_entry.add_css_class("message-input-entry"); - message_entry.set_placeholder_text("Type a message..."); - message_entry.set_hexpand(true); - message_entry.changed.connect(on_text_changed); - message_entry.activate.connect(on_request_send); - input_box.append(message_entry); + // Create attach button (paperclip) + var attach_button = new Button.from_icon_name("mail-attachment"); + attach_button.set_tooltip_text("Attach file…"); + attach_button.add_css_class("flat"); + attach_button.clicked.connect(on_attach_button_clicked); + input_box.append(attach_button); + + // Create message text view (added after attachment button so button stays to the left) + message_buffer = new TextBuffer(null); + message_view = new TextView.with_buffer(message_buffer); + message_view.add_css_class("message-input-entry"); + message_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR); + message_view.set_hexpand(true); + message_view.set_vexpand(false); + message_view.set_size_request(-1, 12); // intrinsic + message_buffer.changed.connect(on_text_changed); + + // Key controller for sending on Enter (Shift+Enter for newline) + var send_key_ctrl = new EventControllerKey(); + send_key_ctrl.key_pressed.connect((keyval, keycode, state) => { + if (keyval == Gdk.Key.Return && (state & Gdk.ModifierType.SHIFT_MASK) == 0) { + on_request_send(); + return true; // consume + } + return false; + }); + message_view.add_controller(send_key_ctrl); + + // Handle paste events to detect images + message_view.paste_clipboard.connect(on_message_paste_clipboard); + + input_box.append(message_view); // Create send button send_button = new Button(); @@ -196,8 +225,8 @@ class TranscriptContainerView : Adw.Bin if (can_send) { on_send(this); - // Clear the message entry - message_entry.text = ""; + // Clear the message text + message_buffer.set_text(""); // Clear the attachment previews attachment_flow_box.remove_all(); @@ -208,6 +237,65 @@ class TranscriptContainerView : Adw.Bin update_send_button_sensitivity(); } } + + private void on_attach_button_clicked() { + var dialog = new Gtk.FileDialog(); + dialog.set_title("Select attachment"); + dialog.set_accept_label("Attach"); + dialog.set_modal(true); + + // Images only for now + var filter = new Gtk.FileFilter(); + filter.set_filter_name("Images"); + filter.add_mime_type("image/png"); + filter.add_mime_type("image/jpeg"); + filter.add_mime_type("image/gif"); + filter.add_mime_type("image/bmp"); + filter.add_mime_type("image/webp"); + filter.add_mime_type("image/svg+xml"); + filter.add_mime_type("image/tiff"); + + dialog.set_default_filter(filter); + + var parent_window = get_root() as Gtk.Window; + dialog.open.begin(parent_window, null, (obj, res) => { + try { + var file = dialog.open.end(res); + if (file != null) { + upload_file(file); + } + } catch (Error e) { + warning("Failed to open file dialog: %s", e.message); + } + }); + } + + private void on_message_paste_clipboard() { + var display = get_display(); + if (display == null) { + return; + } + + var clipboard = display.get_clipboard(); + if (clipboard == null) { + return; + } + + clipboard.read_texture_async.begin(null, (obj, res) => { + try { + var clip = obj as Gdk.Clipboard; + var texture = clip.read_texture_async.end(res); + if (texture != null) { + string tmp_path = Path.build_filename(Environment.get_tmp_dir(), "clipboard-" + Uuid.string_random() + ".png"); + texture.save_to_png(tmp_path); + var tmp_file = File.new_for_path(tmp_path); + upload_file(tmp_file); + } + } catch (Error e) { + // Ignore if clipboard does not contain image + } + }); + } } class UploadedAttachment diff --git a/src/transcript/transcript-drawing-area.vala b/src/transcript/transcript-drawing-area.vala index 6507301..67e8524 100644 --- a/src/transcript/transcript-drawing-area.vala +++ b/src/transcript/transcript-drawing-area.vala @@ -104,9 +104,11 @@ private class TranscriptDrawingArea : Widget } // Text Bubble - var text_bubble = new TextBubbleLayout(message, this, max_width); - text_bubble.vertical_padding = (last_sender == message.sender) ? 0.0f : 10.0f; - items.add(text_bubble); + if (message.text.length > 0 && !message.is_attachment_marker) { + var text_bubble = new TextBubbleLayout(message, this, max_width); + text_bubble.vertical_padding = (last_sender == message.sender) ? 0.0f : 10.0f; + items.add(text_bubble); + } // Check for attachments. For each one, add an image layout bubble foreach (var attachment in message.attachments) { diff --git a/src/transcript/transcript-view.vala b/src/transcript/transcript-view.vala index e2555cc..b05170f 100644 --- a/src/transcript/transcript-view.vala +++ b/src/transcript/transcript-view.vala @@ -50,6 +50,9 @@ public class TranscriptView : Adw.Bin public TranscriptView() { container = new Adw.ToolbarView(); set_child(container); + + // 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");