Switch from Entry to TextView for multiline, paste support for attachments
This commit is contained in:
@@ -12,6 +12,9 @@ public class KordophoneApp : Adw.Application
|
|||||||
protected override void startup () {
|
protected override void startup () {
|
||||||
base.startup ();
|
base.startup ();
|
||||||
|
|
||||||
|
// Set default icon theme
|
||||||
|
Gtk.Settings.get_default().set_property("gtk-icon-theme-name", "Adwaita");
|
||||||
|
|
||||||
// Load CSS from resources
|
// Load CSS from resources
|
||||||
var provider = new Gtk.CssProvider ();
|
var provider = new Gtk.CssProvider ();
|
||||||
provider.load_from_resource ("/net/buzzert/kordophone2/style.css");
|
provider.load_from_resource ("/net/buzzert/kordophone2/style.css");
|
||||||
|
|||||||
@@ -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) {
|
public Message(string text, DateTime date, string? sender) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.date = date;
|
this.date = date;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
|
|
||||||
.message-input-entry {
|
.message-input-entry {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid alpha(@borders, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-preview-row {
|
.attachment-preview-row {
|
||||||
@@ -38,7 +41,6 @@
|
|||||||
|
|
||||||
.attachment-preview {
|
.attachment-preview {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid alpha(@borders, 0.5);
|
border: 1px solid alpha(@borders, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
|
using Gee;
|
||||||
using Gtk;
|
using Gtk;
|
||||||
|
|
||||||
|
private class SizeCache
|
||||||
|
{
|
||||||
|
private static SizeCache instance = null;
|
||||||
|
private HashMap<string, Graphene.Size?> size_cache = new HashMap<string, Graphene.Size?>();
|
||||||
|
|
||||||
|
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
|
private class ImageBubbleLayout : BubbleLayout
|
||||||
{
|
{
|
||||||
public string image_path;
|
public string image_path;
|
||||||
@@ -25,6 +47,12 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
return;
|
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 to load the image to get its dimensions
|
||||||
try {
|
try {
|
||||||
warning("No image size provided, loading image to get dimensions");
|
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();
|
var original_height = (float)texture.get_height();
|
||||||
|
|
||||||
this.image_size = Graphene.Size() { width = original_width, height = original_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) {
|
} catch (Error e) {
|
||||||
// Fallback dimensions if image can't be loaded
|
// Fallback dimensions if image can't be loaded
|
||||||
warning("Failed to load image %s: %s", image_path, e.message);
|
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() {
|
public override float get_height() {
|
||||||
var scale_factor = float.min(max_width / image_size.width, 1.0f);
|
return float.max(intrinsic_height, 100.0f);
|
||||||
return image_size.height * scale_factor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override float get_width() {
|
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) {
|
public override void draw_content(Snapshot snapshot) {
|
||||||
@@ -73,9 +115,15 @@ private class ImageBubbleLayout : BubbleLayout
|
|||||||
|
|
||||||
var image_rect = Graphene.Rect () {
|
var image_rect = Graphene.Rect () {
|
||||||
origin = Graphene.Point() { x = 0, y = 0 },
|
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) {
|
if (cached_texture != null) {
|
||||||
snapshot.append_texture(cached_texture, image_rect);
|
snapshot.append_texture(cached_texture, image_rect);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Gtk;
|
using Gtk;
|
||||||
using Adw;
|
using Adw;
|
||||||
using Gee;
|
using Gee;
|
||||||
|
using Gdk;
|
||||||
|
using GLib;
|
||||||
|
|
||||||
class TranscriptContainerView : Adw.Bin
|
class TranscriptContainerView : Adw.Bin
|
||||||
{
|
{
|
||||||
@@ -11,14 +13,17 @@ class TranscriptContainerView : Adw.Bin
|
|||||||
private Button send_button;
|
private Button send_button;
|
||||||
private FlowBox attachment_flow_box;
|
private FlowBox attachment_flow_box;
|
||||||
|
|
||||||
private Entry message_entry;
|
private TextView message_view;
|
||||||
|
private TextBuffer message_buffer;
|
||||||
private HashSet<string> pending_uploads;
|
private HashSet<string> pending_uploads;
|
||||||
private HashMap<string, AttachmentPreview> attachment_previews;
|
private HashMap<string, AttachmentPreview> attachment_previews;
|
||||||
private ArrayList<UploadedAttachment> completed_attachments;
|
private ArrayList<UploadedAttachment> completed_attachments;
|
||||||
|
|
||||||
public string message_body {
|
public string message_body {
|
||||||
get {
|
owned get {
|
||||||
return message_entry.text;
|
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 {
|
private bool can_send {
|
||||||
get {
|
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
|
// Connect to repository signals
|
||||||
Repository.get_instance().attachment_uploaded.connect(on_attachment_uploaded);
|
Repository.get_instance().attachment_uploaded.connect(on_attachment_uploaded);
|
||||||
|
|
||||||
// Create message entry
|
// Create attach button (paperclip)
|
||||||
message_entry = new Entry();
|
var attach_button = new Button.from_icon_name("mail-attachment");
|
||||||
message_entry.add_css_class("message-input-entry");
|
attach_button.set_tooltip_text("Attach file…");
|
||||||
message_entry.set_placeholder_text("Type a message...");
|
attach_button.add_css_class("flat");
|
||||||
message_entry.set_hexpand(true);
|
attach_button.clicked.connect(on_attach_button_clicked);
|
||||||
message_entry.changed.connect(on_text_changed);
|
input_box.append(attach_button);
|
||||||
message_entry.activate.connect(on_request_send);
|
|
||||||
input_box.append(message_entry);
|
// 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
|
// Create send button
|
||||||
send_button = new Button();
|
send_button = new Button();
|
||||||
@@ -196,8 +225,8 @@ class TranscriptContainerView : Adw.Bin
|
|||||||
if (can_send) {
|
if (can_send) {
|
||||||
on_send(this);
|
on_send(this);
|
||||||
|
|
||||||
// Clear the message entry
|
// Clear the message text
|
||||||
message_entry.text = "";
|
message_buffer.set_text("");
|
||||||
|
|
||||||
// Clear the attachment previews
|
// Clear the attachment previews
|
||||||
attachment_flow_box.remove_all();
|
attachment_flow_box.remove_all();
|
||||||
@@ -208,6 +237,65 @@ class TranscriptContainerView : Adw.Bin
|
|||||||
update_send_button_sensitivity();
|
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
|
class UploadedAttachment
|
||||||
|
|||||||
@@ -104,9 +104,11 @@ private class TranscriptDrawingArea : Widget
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Text Bubble
|
// Text Bubble
|
||||||
var text_bubble = new TextBubbleLayout(message, this, max_width);
|
if (message.text.length > 0 && !message.is_attachment_marker) {
|
||||||
text_bubble.vertical_padding = (last_sender == message.sender) ? 0.0f : 10.0f;
|
var text_bubble = new TextBubbleLayout(message, this, max_width);
|
||||||
items.add(text_bubble);
|
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
|
// Check for attachments. For each one, add an image layout bubble
|
||||||
foreach (var attachment in message.attachments) {
|
foreach (var attachment in message.attachments) {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class TranscriptView : Adw.Bin
|
|||||||
container = new Adw.ToolbarView();
|
container = new Adw.ToolbarView();
|
||||||
set_child(container);
|
set_child(container);
|
||||||
|
|
||||||
|
// Set minimum width for the transcript view
|
||||||
|
set_size_request(330, -1);
|
||||||
|
|
||||||
scrolled_window.set_child(transcript_drawing_area);
|
scrolled_window.set_child(transcript_drawing_area);
|
||||||
scrolled_window.add_css_class("message-list-scroller");
|
scrolled_window.add_css_class("message-list-scroller");
|
||||||
container.set_content(scrolled_window);
|
container.set_content(scrolled_window);
|
||||||
|
|||||||
Reference in New Issue
Block a user