diff options
Diffstat (limited to 'main/src/ui')
17 files changed, 1396 insertions, 93 deletions
diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 83290743..cb3d52e4 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -14,6 +14,9 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { public MainWindow window; public MainWindowController controller; + private LauncherBadge? launcher_badge; + private StatusNotifierItem? status_notifier_item; + private CssProvider custom_css_provider; public Database db { get; set; } public Dino.Entities.Settings settings { get; set; } @@ -31,6 +34,13 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { public Application() throws Error { Object(application_id: "im.dino.Dino", flags: ApplicationFlags.HANDLES_OPEN); init(); + custom_css_provider = new CssProvider(); + settings.notify["custom-css"].connect(update_custom_css); + update_custom_css(); + Gdk.Display? display = Gdk.Display.get_default(); + if (display != null) { + StyleContext.add_provider_for_display(display, custom_css_provider, STYLE_PROVIDER_PRIORITY_USER); + } Environment.set_application_name("Dino"); Window.set_default_icon_name("im.dino.Dino"); @@ -43,6 +53,18 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { Process.exit(0); } + try { + status_notifier_item = new StatusNotifierItem(this); + } catch (IOError e) { + warning("Could not create Dino's tray icon: %s", e.message); + } + + try { + launcher_badge = new LauncherBadge(stream_interactor); + } catch (IOError e) { + warning("Could not create Dino's launcher badge: %s", e.message); + } + NotificationEvents notification_events = stream_interactor.get_module(NotificationEvents.IDENTITY); get_notifications_dbus.begin((_, res) => { // It might take a bit to get the interface. NotificationEvents will queue any notifications in the meantime. @@ -68,18 +90,37 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { stream_interactor.get_module(FileManager.IDENTITY).add_metadata_provider(new Util.AudioVideoFileMetadataProvider()); }); + shutdown.connect(() => { + if (launcher_badge != null) { + launcher_badge.shutdown(); + launcher_badge = null; + } + if (status_notifier_item != null) { + status_notifier_item.shutdown(); + status_notifier_item = null; + } + }); + activate.connect(() => { if (window == null) { controller = new MainWindowController(this, stream_interactor, db); config = new Config(db); window = new MainWindow(this, stream_interactor, db, config); controller.set_window(window); - if ((get_flags() & ApplicationFlags.IS_SERVICE) == ApplicationFlags.IS_SERVICE) window.hide_on_close = true; + window.hide_on_close = true; } window.present(); }); } + private void update_custom_css() { +#if GTK_4_12 + custom_css_provider.load_from_string(settings.custom_css); +#else + custom_css_provider.load_from_data(settings.custom_css.data); +#endif + } + public void handle_uri(string jid, string query, Gee.Map<string, string> options) { switch (query) { case "join": @@ -129,6 +170,12 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { about_action.activate.connect(show_about_window); add_action(about_action); + SimpleAction mark_all_as_read_action = new SimpleAction("mark_all_as_read", null); + mark_all_as_read_action.activate.connect(() => { + stream_interactor.get_module(ChatInteraction.IDENTITY).mark_all_as_read.begin(); + }); + add_action(mark_all_as_read_action); + SimpleAction quit_action = new SimpleAction("quit", null); quit_action.activate.connect(quit); add_action(quit_action); @@ -316,4 +363,3 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { dialog.present(); } } - diff --git a/main/src/ui/conversation_content_view/code_block_widget.vala b/main/src/ui/conversation_content_view/code_block_widget.vala new file mode 100644 index 00000000..3b24c944 --- /dev/null +++ b/main/src/ui/conversation_content_view/code_block_widget.vala @@ -0,0 +1,225 @@ +using Gee; +using Gdk; +using Gtk; +using Pango; + +namespace Dino.Ui.ConversationSummary { + +public class CodeBlockPart : Object { + public bool is_code; + public string text; + public string language; + public int source_start_char; + + public CodeBlockPart.from_text(string text, int source_start_char) { + this.text = text; + this.source_start_char = source_start_char; + this.language = ""; + } + + public CodeBlockPart.from_code(string text, string language) { + this.is_code = true; + this.text = text; + this.language = language; + } +} + +public class CodeBlockParser : Object { + private static Regex? fence_regex; + + public static ArrayList<CodeBlockPart> parse(string text, out bool has_blocks) { + var parts = new ArrayList<CodeBlockPart>(); + Regex regex = get_fence_regex(); + MatchInfo match_info; + int offset = 0; + has_blocks = false; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + parts.add(new CodeBlockPart.from_text(text, 0)); + return parts; + } + while (match_info.matches()) { + int match_start; + int match_end; + int code_start; + int code_end; + match_info.fetch_pos(0, out match_start, out match_end); + match_info.fetch_pos(2, out code_start, out code_end); + int text_end = match_start; + if (text_end > offset && text[text_end - 1] == '\n') { + text_end--; + if (text_end > offset && text[text_end - 1] == '\r') text_end--; + } + if (text_end > offset) { + parts.add(new CodeBlockPart.from_text(text.substring(offset, text_end - offset), text.substring(0, offset).char_count())); + } + if (code_end > code_start && text[code_end - 1] == '\n') { + code_end--; + if (code_end > code_start && text[code_end - 1] == '\r') code_end--; + } + string code = text.substring(code_start, code_end - code_start).replace("\r\n", "\n"); + parts.add(new CodeBlockPart.from_code(code, match_info.fetch(1).strip())); + has_blocks = true; + offset = match_end; + if (offset >= text.length) break; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + break; + } + } + if (offset < text.length) { + parts.add(new CodeBlockPart.from_text(text.substring(offset), text.substring(0, offset).char_count())); + } + return parts; + } + + private static Regex get_fence_regex() { + if (fence_regex == null) { + try { + fence_regex = new Regex("(?ms)^```[\\t ]*([^\\r\\n`]*)[\\t ]*\\r?\\n(.*?)^```[\\t ]*(?:\\r?\\n|$)", RegexCompileFlags.OPTIMIZE); + } catch (RegexError error) { + GLib.error("Invalid code block expression: %s", error.message); + } + } + return fence_regex; + } +} + +public class CodeBlockWidget : Box { + private string code; + private CodeSyntax.LanguageDefinition? definition; + private Label code_label; + private ulong realize_id; + + public CodeBlockWidget(string code, string language) { + Object(orientation: Orientation.VERTICAL, spacing: 0, halign: Align.START, overflow: Overflow.HIDDEN); + this.code = code; + definition = CodeSyntax.find(language); + add_css_class("code-block"); + + var header = new Box(Orientation.HORIZONTAL, 8); + header.add_css_class("code-block-header"); + + string language_name = definition != null ? definition.name : language.strip(); + if (language_name == "") language_name = _("Plain text"); + var language_label = new Label(language_name) { xalign=0, hexpand=true }; + language_label.add_css_class("code-block-language"); + header.append(language_label); + + int line_count = code == "" ? 0 : code.split("\n").length; + var line_label = new Label(n("%d line", "%d lines", line_count).printf(line_count)); + line_label.add_css_class("code-block-meta"); + header.append(line_label); + + var download_button = new Button.from_icon_name("dino-file-download-symbolic") { + tooltip_text=_("Download code") + }; + download_button.add_css_class("flat"); + download_button.add_css_class("code-block-download"); + download_button.clicked.connect(save); + header.append(download_button); + append(header); + + code_label = new Label(code) { + use_markup=false, + selectable=true, + xalign=0, + yalign=0, + wrap=false + }; + code_label.add_css_class("message-text"); + code_label.add_css_class("code-block-content"); + append(code_label); + + apply_highlighting(); + realize_id = realize.connect(apply_highlighting); + } + + private void apply_highlighting() { + var attributes = new AttrList(); + if (definition != null) { + foreach (CodeSyntax.HighlightSpan span in definition.highlight(code)) { + RGBA color = get_token_color(span.style); + Attribute foreground = Pango.attr_foreground_new( + (uint16) Math.round(color.red * uint16.MAX), + (uint16) Math.round(color.green * uint16.MAX), + (uint16) Math.round(color.blue * uint16.MAX) + ); + foreground.start_index = span.start; + foreground.end_index = span.end; + attributes.insert(foreground.copy()); + if (color.alpha < 1) { + Attribute alpha = Pango.attr_foreground_alpha_new((uint16) Math.round(color.alpha * uint16.MAX)); + alpha.start_index = span.start; + alpha.end_index = span.end; + attributes.insert(alpha.copy()); + } + } + } + code_label.attributes = attributes; + } + + private RGBA get_token_color(CodeSyntax.TokenStyle style) { + StyleContext context = code_label.get_style_context(); + RGBA color = context.get_color(); + string? color_name = null; + switch (style) { + case CodeSyntax.TokenStyle.COMMENT: + color.alpha = 0.62f; + break; + case CodeSyntax.TokenStyle.CONSTANT: + color_name = "error_color"; + break; + case CodeSyntax.TokenStyle.NUMBER: + case CodeSyntax.TokenStyle.PREPROCESSOR: + color_name = "warning_color"; + break; + case CodeSyntax.TokenStyle.STRING: + case CodeSyntax.TokenStyle.TYPE: + color_name = "success_color"; + break; + case CodeSyntax.TokenStyle.FUNCTION: + case CodeSyntax.TokenStyle.KEYWORD: + color_name = "accent_color"; + break; + } + if (color_name != null) { + RGBA theme_color; + if (context.lookup_color(color_name, out theme_color)) color = theme_color; + } + return color; + } + + private void save() { + var dialog = new FileChooserNative(_("Save code as…"), get_root() as Gtk.Window, FileChooserAction.SAVE, _("Save"), _("Cancel")); + dialog.modal = true; + dialog.set_current_name("code." + (definition != null ? definition.extension : "txt")); + dialog.response.connect((response_id) => { + if (response_id == ResponseType.ACCEPT) { + File? target = dialog.get_file(); + if (target != null) { + try { + string? etag; + target.replace_contents(code.data, null, false, FileCreateFlags.REPLACE_DESTINATION, out etag); + } catch (Error error) { + warning("Failed to save code: %s", error.message); + } + } + } + dialog.destroy(); + }); + dialog.show(); + } + + public override void dispose() { + if (realize_id != 0) { + disconnect(realize_id); + realize_id = 0; + } + base.dispose(); + } +} + +} diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index 6a686f1b..83e86827 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -55,6 +55,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug Widget currently_highlighted = null; ContentMetaItem? current_meta_item = null; double last_y = -1; + private bool message_text_selectable = true; private Button create_action_button(string action_name) { var button = new Button() { name=action_name }; @@ -82,6 +83,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug create_action_button("reply"); create_action_button("delete"); + create_action_button("copy"); var menu_button = new MenuButton() { name="menu", tooltip_text=_("More actions") }; action_buttons["menu"] = menu_button; @@ -127,7 +129,10 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // reaches the overlapping part of a button. EventControllerMotion main_motion_events = new EventControllerMotion(); main.add_controller(main_motion_events); - main_motion_events.motion.connect(update_highlight); + main_motion_events.motion.connect((x, y) => { + set_message_text_selectable(true); + update_highlight(x, y); + }); // Process touch events and capture phase to allow highlighting a message without cursor GestureClick click_controller = new GestureClick(); @@ -135,12 +140,23 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug click_controller.propagation_phase = Gtk.PropagationPhase.CAPTURE; main_wrap_box.add_controller(click_controller); click_controller.pressed.connect_after((n, x, y) => { + set_message_text_selectable(false); update_highlight(x, y); }); return this; } + private void set_message_text_selectable(bool selectable) { + if (message_text_selectable == selectable) return; + + message_text_selectable = selectable; + foreach (Plugins.MetaConversationItem item in meta_items) { + MessageMetaItem? message_item = item as MessageMetaItem; + if (message_item != null) message_item.set_text_selectable(selectable); + } + } + public void activate_last_message_correction() { Gee.BidirIterator<Plugins.MetaConversationItem> iter = content_items.bidir_iterator(); iter.last(); @@ -578,6 +594,9 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug private Widget insert_new(Plugins.MetaConversationItem item) { Plugins.MetaConversationItem? lower_item = meta_items.lower(item); + MessageMetaItem? message_item = item as MessageMetaItem; + if (message_item != null) message_item.set_text_selectable(message_text_selectable); + // Fill datastructure ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item); item_item_skeletons[item] = item_skeleton; diff --git a/main/src/ui/conversation_content_view/file_video_widget.vala b/main/src/ui/conversation_content_view/file_video_widget.vala new file mode 100644 index 00000000..703428d9 --- /dev/null +++ b/main/src/ui/conversation_content_view/file_video_widget.vala @@ -0,0 +1,174 @@ +using Gtk; + +using Dino.Entities; + +namespace Dino.Ui { + +public class FileVideoWidget : Widget { + private const int MIN_WIDTH = 100; + private const int MIN_HEIGHT = 100; + private const int MAX_WIDTH = 600; + private const int MAX_HEIGHT = 300; + + private Overlay overlay = new Overlay(); + private FixedRatioVideo video = new FixedRatioVideo() { + min_width = MIN_WIDTH, + min_height = MIN_HEIGHT, + max_width = MAX_WIDTH, + max_height = MAX_HEIGHT + }; + private Gtk.Box video_overlay_toolbar = new Gtk.Box(Orientation.VERTICAL, 0) { + halign = Align.END, + valign = Align.START, + margin_top = 10, + margin_start = 10, + margin_end = 10, + margin_bottom = 10, + vexpand = false, + visible = false + }; + + construct { + layout_manager = new BinLayout(); + check_widget_leak(this); + } + + public FileVideoWidget(FileTransfer file_transfer) { + this.halign = Align.START; + this.add_css_class("file-video-widget"); + + MenuButton button = new MenuButton(); + button.icon_name = "dino-view-more"; + Menu menu_model = new Menu(); + menu_model.append(_("Open"), "file.open"); + menu_model.append(_("Save as…"), "file.save_as"); + Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model); + button.popover = popover_menu; + video_overlay_toolbar.append(button); + video_overlay_toolbar.add_css_class("card"); + video_overlay_toolbar.add_css_class("toolbar"); + video_overlay_toolbar.add_css_class("compact-card-toolbar"); + video_overlay_toolbar.set_cursor_from_name("default"); + + video.file = file_transfer.get_file(); + + overlay.set_child(video); + overlay.add_overlay(video_overlay_toolbar); + overlay.set_clip_overlay(video_overlay_toolbar, true); + overlay.insert_after(this, null); + + EventControllerMotion motion_events = new EventControllerMotion(); + this.add_controller(motion_events); + motion_events.enter.connect(() => { + video_overlay_toolbar.visible = true; + }); + motion_events.leave.connect(() => { + if (button.popover != null && button.popover.visible) return; + video_overlay_toolbar.visible = false; + }); + } + + public static bool can_display(FileTransfer file_transfer) { + if (file_transfer.state != FileTransfer.State.COMPLETE || file_transfer.path == null || file_transfer.content_type == null) { + return false; + } + + string? mime_type = file_transfer.content_type.get_mime_type(); + return mime_type != null && mime_type.has_prefix("video/"); + } + + public override void dispose() { + if (video.media_stream != null) video.media_stream.pause(); + if (overlay != null && overlay.parent != null) overlay.unparent(); + base.dispose(); + } +} + +private class FixedRatioVideo : Widget { + public int min_width { get; set; default = -1; } + public int max_width { get; set; default = int.MAX; } + public int min_height { get; set; default = -1; } + public int max_height { get; set; default = int.MAX; } + public File? file { get { return inner.file; } set { inner.file = value; } } + public MediaStream? media_stream { get { return inner.media_stream; } } + + private Gtk.Video inner = new Gtk.Video() { + autoplay = false, + loop = false + }; + + construct { + set_css_name("video"); + inner.insert_after(this, null); + this.notify.connect(queue_resize); + check_widget_leak(this); + } + + private void measure_target_size(out int width, out int height) { + if (width_request != -1 && height_request != -1) { + width = width_request; + height = height_request; + return; + } + + width = min_width; + height = min_height; + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.HORIZONTAL, -1, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + width = int.max(child_nat, width); + } + width = int.min(width, max_width); + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.VERTICAL, width, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + height = int.max(child_nat, height); + } + + if (height > max_height) { + height = max_height; + width = min_width; + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.HORIZONTAL, max_height, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + width = int.max(child_nat, width); + } + width = int.min(width, max_width); + } + } + + public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) { + minimum_baseline = -1; + natural_baseline = -1; + + int width, height; + measure_target_size(out width, out height); + if (orientation == Orientation.HORIZONTAL) { + minimum = min_width; + natural = int.max(min_width, int.min(width, max_width)); + } else if (for_size == -1) { + minimum = min_height; + natural = int.max(min_height, int.min(height, max_height)); + } else { + minimum = natural = int.max(min_height, int.min(height * for_size / width, height)); + } + } + + public override void size_allocate(int width, int height, int baseline) { + if (inner.should_layout()) inner.allocate(width, height, baseline, null); + } + + public override SizeRequestMode get_request_mode() { + return SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void dispose() { + inner.unparent(); + base.dispose(); + } +} + +} diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index 04185343..d6d2415a 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -46,6 +46,7 @@ public class FileWidget : SizeRequestBin { enum State { IMAGE, + VIDEO, DEFAULT } @@ -77,6 +78,7 @@ public class FileWidget : SizeRequestBin { private async void update_widget() { bool show_image = FileImageWidget.can_display(file_transfer); + bool show_video = FileVideoWidget.can_display(file_transfer); if (show_image && state != State.IMAGE) { var content_bak = content; @@ -97,7 +99,19 @@ public class FileWidget : SizeRequestBin { } catch (Error e) { } } - if (!show_image && state != State.DEFAULT) { + if (show_image && state == State.IMAGE) return; + + if (show_video && state != State.VIDEO) { + if (content != null) content.unparent(); + content = new FileVideoWidget(file_transfer); + state = State.VIDEO; + content.insert_after(this, null); + return; + } + + if (show_video && state == State.VIDEO) return; + + if (state != State.DEFAULT) { if (content != null) content.unparent(); FileDefaultWidget default_file_widget = new FileDefaultWidget(); default_widget_controller = new FileDefaultWidgetController(default_file_widget); diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 25d3775b..c5a9c145 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -30,12 +30,16 @@ public class MessageMetaItem : ContentMetaItem { uint pending_timeout_id = -1; public Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, hexpand=true, vexpand=true }; + private Widget? content_widget; + private bool text_selectable = true; public MessageMetaItem(ContentItem content_item, StreamInteractor stream_interactor) { base(content_item); message_item = content_item as MessageItem; this.stream_interactor = stream_interactor; + label.add_css_class("message-text"); + stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.connect(on_updated_item); stream_interactor.get_module(MessageDeletion.IDENTITY).item_deleted.connect(on_updated_item); @@ -73,111 +77,176 @@ public class MessageMetaItem : ContentMetaItem { update_label(); } - private void generate_markup_text(ContentItem item, Label label) { - MessageItem message_item = item as MessageItem; - Conversation conversation = message_item.conversation; + private void generate_markup_text(Label label, string text, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { Message message = message_item.message; + label.set_attributes(create_text_attributes(text, 0, markups, action_name_length)); - // Get a copy of the markup spans, such that we can modify them - var markups = new ArrayList<Xep.MessageMarkup.Span>(); - foreach (var markup in message.get_markups()) { - markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char }); + bool theme_dependent; + string markup_text = parse_text_markup(label, text, out theme_dependent); + + int only_emoji_count = Util.get_only_emoji_count(markup_text); + if (only_emoji_count != -1) { + string size_str = only_emoji_count < 5 ? "xx-large" : "large"; + markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>"; } - string markup_text = message.body; + string dim_color = Util.is_dark_theme(label) ? "#BDBDBD" : "#707070"; - var attrs = new AttrList(); - label.set_attributes(attrs); + if (message.body == "") { + markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted")); + theme_dependent = true; + } + markup_text += get_meta_markup(label, true, ref theme_dependent); - if (markup_text == null) return; // TODO remove + if (theme_dependent && realize_id == -1 && !label.get_realized()) { + realize_id = label.realize.connect(update_label); + } else if (!theme_dependent && realize_id != -1) { + label.disconnect(realize_id); + } + label.label = markup_text; + } - // Only process messages up to a certain size - if (markup_text.length > 10000) { - markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; + private ArrayList<Xep.MessageMarkup.Span> copy_markups(Message message) { + var markups = new ArrayList<Xep.MessageMarkup.Span>(); + foreach (var markup in message.get_markups()) { + markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char }); } + return markups; + } - bool theme_dependent = false; + private string prepare_message(ArrayList<Xep.MessageMarkup.Span> markups, out int action_name_length, bool truncate = true) { + Message message = message_item.message; + string text = message.body ?? ""; + action_name_length = 0; - markup_text = Util.remove_fallbacks_adjust_markups(markup_text, message.quoted_item_id > 0, message.get_fallbacks(), markups); + if (truncate && text.char_count() > 10000) { + text = text.substring(0, text.index_of_nth_char(10000)) + " [" + _("Message too long") + "]"; + } - var bold_attr = Pango.attr_weight_new(Pango.Weight.BOLD); - var italic_attr = Pango.attr_style_new(Pango.Style.ITALIC); - var strikethrough_attr = Pango.attr_strikethrough_new(true); + text = Util.remove_fallbacks_adjust_markups(text, message.quoted_item_id > 0, message.get_fallbacks(), markups); - // Prefix message with name instead of /me - if (markup_text.has_prefix("/me ")) { - string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from); - markup_text = display_name + " " + markup_text.substring(4); + if (text.has_prefix("/me ")) { + string display_name = Util.get_participant_display_name(stream_interactor, message_item.conversation, message.from); + text = display_name + " " + text.substring(4); + action_name_length = display_name.char_count(); foreach (Xep.MessageMarkup.Span span in markups) { - int length = display_name.char_count() - 4 + 1; - span.start_char += length; - span.end_char += length; + int difference = action_name_length - 3; + span.start_char += difference; + span.end_char += difference; } + } + + return text; + } - bold_attr.end_index = display_name.length; - italic_attr.end_index = display_name.length; - attrs.insert(bold_attr.copy()); - attrs.insert(italic_attr.copy()); + private Label create_text_label() { + var text_label = new Label("") { + use_markup=true, + xalign=0, + selectable=text_selectable, + wrap=true, + wrap_mode=Pango.WrapMode.WORD_CHAR, + hexpand=true + }; + text_label.add_css_class("message-text"); + text_label.activate_link.connect(on_label_activate_link); + return text_label; + } + + public void set_text_selectable(bool selectable) { + text_selectable = selectable; + if (content_widget != null) set_text_selectable_recursive(content_widget, selectable); + } + + private void set_text_selectable_recursive(Widget widget, bool selectable) { + Label? text_label = widget as Label; + if (text_label != null && text_label.has_css_class("message-text")) { + text_label.selectable = selectable; + } + + for (Widget? child = widget.get_first_child(); child != null; child = child.get_next_sibling()) { + set_text_selectable_recursive(child, selectable); } + } + + private void add_text_attribute(AttrList attributes, Attribute attribute, string text, int source_start, int span_start, int span_end) { + int source_end = source_start + text.char_count(); + int start = int.max(source_start, span_start); + int end = int.min(source_end, span_end); + if (start >= end) return; + + attribute.start_index = text.index_of_nth_char(start - source_start); + attribute.end_index = text.index_of_nth_char(end - source_start); + attributes.insert(attribute.copy()); + } + private AttrList create_text_attributes(string text, int source_start, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + var attributes = new AttrList(); foreach (var markup in markups) { - foreach (var ty in markup.types) { - Attribute attr = null; - switch (ty) { + foreach (var type in markup.types) { + Attribute attribute; + switch (type) { case Xep.MessageMarkup.SpanType.EMPHASIS: - attr = Pango.attr_style_new(Pango.Style.ITALIC); + attribute = Pango.attr_style_new(Pango.Style.ITALIC); break; case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS: - attr = Pango.attr_weight_new(Pango.Weight.BOLD); + attribute = Pango.attr_weight_new(Pango.Weight.BOLD); break; case Xep.MessageMarkup.SpanType.DELETED: - attr = Pango.attr_strikethrough_new(true); + attribute = Pango.attr_strikethrough_new(true); break; + default: + continue; } - attr.start_index = markup_text.index_of_nth_char(markup.start_char); - attr.end_index = markup_text.index_of_nth_char(markup.end_char); - attrs.insert(attr.copy()); + add_text_attribute(attributes, (owned) attribute, text, source_start, markup.start_char, markup.end_char); } } - // Work around pango bug - markup_text = Util.unbreak_space_around_non_spacing_mark((owned) markup_text); + if (action_name_length > 0) { + add_text_attribute(attributes, Pango.attr_weight_new(Pango.Weight.BOLD), text, source_start, 0, action_name_length); + add_text_attribute(attributes, Pango.attr_style_new(Pango.Style.ITALIC), text, source_start, 0, action_name_length); + } + + return attributes; + } + private string parse_text_markup(Label text_label, string text, out bool theme_dependent) { + string markup_text = Util.unbreak_space_around_non_spacing_mark(text); + theme_dependent = false; + Conversation conversation = message_item.conversation; if (conversation.type_ == Conversation.Type.GROUPCHAT) { - markup_text = Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent); - } else { - markup_text = Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent); + return Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent); } + return Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent); + } - int only_emoji_count = Util.get_only_emoji_count(markup_text); - if (only_emoji_count != -1) { - string size_str = only_emoji_count < 5 ? "xx-large" : "large"; - markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>"; - } + private void format_text_segment(Label text_label, CodeBlockPart part, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + text_label.set_attributes(create_text_attributes(part.text, part.source_start_char, markups, action_name_length)); + bool theme_dependent; + text_label.label = parse_text_markup(text_label, part.text, out theme_dependent); + } - string dim_color = Util.is_dark_theme(this.label) ? "#BDBDBD" : "#707070"; + private string get_meta_markup(Label meta_label, bool inline, ref bool theme_dependent) { + Message message = message_item.message; + string dim_color = Util.is_dark_theme(meta_label) ? "#BDBDBD" : "#707070"; + string separator = inline ? " " : ""; + string markup = ""; - if (message.body == "") { - markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted")); - theme_dependent = true; - } if (message.edit_to != null) { - markup_text += @" <span size='small' color='$dim_color'>(%s)</span>".printf(_("edited")); + markup = @"$separator<span size='small' color='$dim_color'>(%s)</span>".printf(_("edited")); + separator = " "; theme_dependent = true; } - // Append message status info additional_info = AdditionalInfo.NONE; if (message.direction == Message.DIRECTION_SENT && (message.marked == Message.Marked.SENDING || message.marked == Message.Marked.UNSENT)) { - // Append "pending..." iff message has not been sent yet if (message.time.compare(new DateTime.now_utc().add_seconds(-10)) < 0) { - markup_text += @" <span size='small' color='$dim_color'>%s</span>".printf(_("pending…")); + markup += @"$separator<span size='small' color='$dim_color'>%s</span>".printf(_("pending…")); theme_dependent = true; additional_info = AdditionalInfo.PENDING; } else { int time_diff = (- (int) message.time.difference(new DateTime.now_utc()) / 1000); - if (pending_timeout_id != -1) Source.remove(pending_timeout_id); pending_timeout_id = Timeout.add(10000 - time_diff, () => { update_label(); pending_timeout_id = -1; @@ -185,24 +254,63 @@ public class MessageMetaItem : ContentMetaItem { }); } } else if (message.direction == Message.DIRECTION_SENT && message.marked == Message.Marked.ERROR) { - // Append "delivery failed" if there was a server error - // TODO: Use a dedicated label, not appended text - string error_color = Util.rgba_to_hex(Util.get_label_pango_color(label, "error_color")); - markup_text += " <span size='small' color='%s'>%s</span>".printf(error_color, _("delivery failed")); + string error_color = Util.rgba_to_hex(Util.get_label_pango_color(meta_label, "error_color")); + markup += @"$separator<span size='small' color='$error_color'>%s</span>".printf(_("delivery failed")); theme_dependent = true; additional_info = AdditionalInfo.DELIVERY_FAILED; } - if (theme_dependent && realize_id == -1) { - realize_id = label.realize.connect(update_label); - } else if (!theme_dependent && realize_id != -1) { - label.disconnect(realize_id); + return markup; + } + + private Widget generate_code_widget(ArrayList<CodeBlockPart> parts, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + var content = new Box(Orientation.VERTICAL, 6) { hexpand=true }; + foreach (CodeBlockPart part in parts) { + if (part.is_code) { + content.append(new CodeBlockWidget(part.text, part.language)); + } else { + Label text_label = create_text_label(); + format_text_segment(text_label, part, markups, action_name_length); + content.append(text_label); + } } - label.label = markup_text; + + Label meta_label = create_text_label(); + bool theme_dependent = false; + string meta_markup = get_meta_markup(meta_label, false, ref theme_dependent); + if (meta_markup != "") { + meta_label.label = meta_markup; + content.append(meta_label); + } + return content; } public void update_label() { - generate_markup_text(content_item, label); + if (pending_timeout_id != -1) { + Source.remove(pending_timeout_id); + pending_timeout_id = -1; + } + if (realize_id != -1) { + label.disconnect(realize_id); + realize_id = -1; + } + + var markups = copy_markups(message_item.message); + int action_name_length; + string text = prepare_message(markups, out action_name_length); + bool has_blocks; + ArrayList<CodeBlockPart> parts = CodeBlockParser.parse(text, out has_blocks); + if (has_blocks) { + content_widget = generate_code_widget(parts, markups, action_name_length); + } else { + generate_markup_text(label, text, markups, action_name_length); + content_widget = label; + } + set_text_selectable_recursive(content_widget, text_selectable); + + if (outer != null && !in_edit_mode) { + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); + } } public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) { @@ -210,7 +318,7 @@ public class MessageMetaItem : ContentMetaItem { this.notify["in-edit-mode"].connect(on_in_edit_mode_changed); - outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); if (message_item.message.quoted_item_id > 0) { var quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(message_item.conversation, message_item.message.quoted_item_id); @@ -223,7 +331,7 @@ public class MessageMetaItem : ContentMetaItem { outer.set_widget(quote_widget, Plugins.WidgetType.GTK4, 1); } } - return label; + return content_widget; } public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) { @@ -244,6 +352,19 @@ public class MessageMetaItem : ContentMetaItem { actions.add(action1); } + Plugins.MessageAction copy_action = new Plugins.MessageAction(); + copy_action.name = "copy"; + copy_action.icon_name = "dino-edit-copy-symbolic"; + copy_action.tooltip = _("Copy message"); + copy_action.callback = () => { + var markups = copy_markups(message_item.message); + int action_name_length; + string text = prepare_message(markups, out action_name_length, false); + Gdk.Display? display = Gdk.Display.get_default(); + if (display != null) display.get_clipboard().set_text(text); + }; + actions.add(copy_action); + actions.add(get_reply_action(content_item, message_item.conversation, stream_interactor)); actions.add(get_reaction_action(content_item, message_item.conversation, stream_interactor)); @@ -267,7 +388,7 @@ public class MessageMetaItem : ContentMetaItem { edit_mode.cancelled.connect(() => { in_edit_mode = false; - outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); }); edit_mode.send.connect(() => { string text = edit_mode.chat_text_view.text_view.buffer.text; @@ -275,7 +396,7 @@ public class MessageMetaItem : ContentMetaItem { Dino.send_message(message_item.conversation, text, message_item.message.quoted_item_id, message_item.message, markups); in_edit_mode = false; - outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); }); edit_mode.chat_text_view.set_text(message); diff --git a/main/src/ui/conversation_content_view/quote_widget.vala b/main/src/ui/conversation_content_view/quote_widget.vala index ce0011e1..98aefacb 100644 --- a/main/src/ui/conversation_content_view/quote_widget.vala +++ b/main/src/ui/conversation_content_view/quote_widget.vala @@ -87,11 +87,17 @@ namespace Dino.Ui.Quote { GestureClick gesture_click_controller = new GestureClick(); outer.add_controller(gesture_click_controller); + bool click_stopped = false; gesture_click_controller.pressed.connect(() => { - model.jump_to(); + click_stopped = false; + }); + gesture_click_controller.stopped.connect(() => { + click_stopped = true; + }); + gesture_click_controller.released.connect(() => { + if (!click_stopped) model.jump_to(); }); return outer; } } - diff --git a/main/src/ui/conversation_content_view/syntax_highlighting.vala b/main/src/ui/conversation_content_view/syntax_highlighting.vala new file mode 100644 index 00000000..b45ba81c --- /dev/null +++ b/main/src/ui/conversation_content_view/syntax_highlighting.vala @@ -0,0 +1,136 @@ +using Gee; + +namespace Dino.Ui.CodeSyntax { + +public enum TokenStyle { + COMMENT, + CONSTANT, + FUNCTION, + KEYWORD, + NUMBER, + PREPROCESSOR, + STRING, + TYPE +} + +public class HighlightSpan : Object { + public int start; + public int end; + public TokenStyle style; + + public HighlightSpan(int start, int end, TokenStyle style) { + this.start = start; + this.end = end; + this.style = style; + } +} + +public class HighlightRule : Object { + public TokenStyle style; + private Regex regex; + + public HighlightRule(TokenStyle style, string pattern) { + this.style = style; + try { + regex = new Regex(pattern, RegexCompileFlags.OPTIMIZE); + } catch (RegexError error) { + GLib.error("Invalid syntax highlighting rule: %s", error.message); + } + } + + public bool find(string text, int offset, out int start, out int end) { + MatchInfo match_info; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + start = -1; + end = -1; + return false; + } + if (!match_info.matches()) { + start = -1; + end = -1; + return false; + } + match_info.fetch_pos(0, out start, out end); + return true; + } +} + +public class LanguageDefinition : Object { + public string id; + public string name; + public string extension; + public string[] aliases; + public HighlightRule[] rules; + + public LanguageDefinition(string id, string name, string extension, string[] aliases, HighlightRule[] rules) { + this.id = id; + this.name = name; + this.extension = extension; + this.aliases = aliases; + this.rules = rules; + } + + public bool matches(string language) { + string normalized = language.down().strip(); + if (normalized == id) return true; + foreach (string alias in aliases) { + if (normalized == alias) return true; + } + return false; + } + + public ArrayList<HighlightSpan> highlight(string text) { + var spans = new ArrayList<HighlightSpan>(); + int offset = 0; + while (offset < text.length) { + HighlightRule? selected_rule = null; + int selected_start = text.length; + int selected_end = text.length; + foreach (HighlightRule rule in rules) { + int start; + int end; + if (rule.find(text, offset, out start, out end) && start < selected_start) { + selected_rule = rule; + selected_start = start; + selected_end = end; + } + } + if (selected_rule == null || selected_end <= selected_start) break; + spans.add(new HighlightSpan(selected_start, selected_end, selected_rule.style)); + offset = selected_end; + } + return spans; + } +} + +private static LanguageDefinition[]? definitions; + +public static LanguageDefinition? find(string language) { + if (definitions == null) definitions = create_definitions(); + foreach (LanguageDefinition definition in definitions) { + if (definition.matches(language)) return definition; + } + return null; +} + +private static LanguageDefinition[] create_definitions() { + return { create_c_definition() }; +} + +private static LanguageDefinition create_c_definition() { + HighlightRule[] rules = { + new HighlightRule(TokenStyle.COMMENT, "//[^\\r\\n]*|/\\*[\\s\\S]*?\\*/"), + new HighlightRule(TokenStyle.STRING, "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'"), + new HighlightRule(TokenStyle.PREPROCESSOR, "(?m)^[\\t ]*#[\\t ]*[A-Za-z_][A-Za-z0-9_]*"), + new HighlightRule(TokenStyle.KEYWORD, "\\b(?:alignas|alignof|asm|auto|break|case|const|constexpr|continue|default|do|else|enum|extern|for|goto|if|inline|register|restrict|return|sizeof|static|static_assert|struct|switch|thread_local|typedef|typeof|typeof_unqual|union|volatile|while|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\\b"), + new HighlightRule(TokenStyle.TYPE, "\\b(?:bool|char|char8_t|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Decimal32|_Decimal64|_Decimal128|_Imaginary|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t|intptr_t|uintptr_t|ptrdiff_t|size_t|ssize_t|wchar_t)\\b"), + new HighlightRule(TokenStyle.CONSTANT, "\\b(?:false|NULL|nullptr|true)\\b"), + new HighlightRule(TokenStyle.NUMBER, "\\b(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|(?:[0-9]+\\.[0-9]*|[0-9]+)(?:[eEpP][+-]?[0-9]+)?)[fFlLuU]*\\b"), + new HighlightRule(TokenStyle.FUNCTION, "\\b[A-Za-z_][A-Za-z0-9_]*(?=[\\t ]*\\()") + }; + return new LanguageDefinition("c", "C", "c", { "h", "c89", "c90", "c99", "c11", "c17", "c18", "c23" }, rules); +} + +} diff --git a/main/src/ui/conversation_selector/conversation_selector.vala b/main/src/ui/conversation_selector/conversation_selector.vala index 70e192b7..25ea1b1e 100644 --- a/main/src/ui/conversation_selector/conversation_selector.vala +++ b/main/src/ui/conversation_selector/conversation_selector.vala @@ -14,6 +14,7 @@ public class ConversationSelector : Widget { ListBox list_box = new ListBox() { hexpand=true }; private StreamInteractor stream_interactor; + private string search_text = ""; private HashMap<Conversation, ConversationSelectorRow> rows = new HashMap<Conversation, ConversationSelectorRow>(Conversation.hash_func, Conversation.equals_func); public ConversationSelector init(StreamInteractor stream_interactor) { @@ -38,6 +39,7 @@ public class ConversationSelector : Widget { } construct { + list_box.set_filter_func(filter); list_box.set_sort_func(sort); realize.connect(() => { @@ -53,6 +55,16 @@ public class ConversationSelector : Widget { check_widget_leak(this); } + public void set_search_text(string text) { + search_text = text.strip().casefold(); + list_box.invalidate_filter(); + } + + private bool filter(ListBoxRow list_box_row) { + ConversationSelectorRow? row = list_box_row as ConversationSelectorRow; + return row != null && (search_text == "" || row.matches_search(search_text)); + } + public void row_activated(ListBoxRow r) { ConversationSelectorRow? row = r as ConversationSelectorRow; if (row != null) { diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index 0e565c84..95384a5e 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -13,6 +13,7 @@ namespace Dino.Ui { public class ConversationSelectorRow : ListBoxRow { [GtkChild] protected unowned AvatarPicture picture; + [GtkChild] protected unowned DrawingArea account_color_indicator; [GtkChild] protected unowned Label name_label; [GtkChild] protected unowned Label time_label; [GtkChild] protected unowned Label nick_label; @@ -42,6 +43,7 @@ public class ConversationSelectorRow : ListBoxRow { var display_name_model = stream_interactor.get_module(ContactModels.IDENTITY).get_display_name_model(conversation); display_name_model.bind_property("display-name", name_label, "label", BindingFlags.SYNC_CREATE); + name_label.notify["label"].connect(changed); // Set tooltip switch (conversation.type_) { @@ -63,6 +65,7 @@ public class ConversationSelectorRow : ListBoxRow { last_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); picture.model = new ViewModel.CompatAvatarPictureModel(stream_interactor).set_conversation(conversation); + account_color_indicator.set_draw_func(draw_account_color); conversation.notify["read-up-to-item"].connect(() => update_read()); conversation.notify["pinned"].connect(() => { update_pinned_icon(); }); @@ -73,9 +76,31 @@ public class ConversationSelectorRow : ListBoxRow { connect_signals_weak(this); } + private static void draw_account_color(DrawingArea area, Cairo.Context context, int width, int height) { + ConversationSelectorRow? row = area.get_ancestor(typeof(ConversationSelectorRow)) as ConversationSelectorRow; + if (row == null) return; + + Gdk.RGBA color = Gdk.RGBA(); + if (!color.parse(Dino.Application.get_default().settings.get_account_color(row.conversation.account))) return; + + Gdk.cairo_set_source_rgba(context, color); + context.rectangle(0, 0, width, height); + context.fill(); + } + private static void connect_signals_weak(ConversationSelectorRow local_self) { var self_weak = WeakRef(local_self); + var settings = Dino.Application.get_default().settings; + ulong account_color_changed_handler_id = 0; + account_color_changed_handler_id = settings.account_color_changed.connect((account, color) => { + ConversationSelectorRow? self = self_weak.get() as ConversationSelectorRow?; + if (self == null) return; + if (self.conversation.account.equals(account)) { + self.account_color_indicator.queue_draw(); + } + }); + ulong room_info_updated_handler_id = 0; if (local_self.conversation.type_ == Conversation.Type.GROUPCHAT) { room_info_updated_handler_id = local_self.stream_interactor.get_module(MucManager.IDENTITY).room_info_updated.connect((account, jid) => { @@ -116,6 +141,7 @@ public class ConversationSelectorRow : ListBoxRow { local_self.weak_ref((self_obj) => { ConversationSelectorRow self = (ConversationSelectorRow) self_obj; + settings.disconnect(account_color_changed_handler_id); if (room_info_updated_handler_id != 0) { self.stream_interactor.get_module(MucManager.IDENTITY).disconnect(room_info_updated_handler_id); } @@ -154,6 +180,11 @@ public class ConversationSelectorRow : ListBoxRow { name_label.label = Util.get_conversation_display_name(stream_interactor, conversation); } + public bool matches_search(string search_text) { + return name_label.label.casefold().contains(search_text) || + conversation.counterpart.bare_jid.to_string().casefold().contains(search_text); + } + private void update_pinned_icon() { pinned_image.visible = conversation.pinned != 0; } diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index 9626a39d..bff8a70b 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -53,6 +53,16 @@ public class ConversationViewController : Object { key_controller3.key_pressed.connect(forward_key_press_to_chat_input); main_window.conversation_headerbar.add_controller(key_controller3); + main_window.conversation_title_button.clicked.connect(() => { + if (conversation == null) return; + + var variant = new Variant.tuple(new Variant[] { + new Variant.int32(conversation.id), + new Variant.string("about") + }); + app.activate_action("open-conversation-details", variant); + }); + // goto-end floating button var vadjustment = view.conversation_frame.scrolled.vadjustment; vadjustment.notify["value"].connect(() => { @@ -113,6 +123,7 @@ public class ConversationViewController : Object { } this.conversation = conversation; + main_window.conversation_title_button.sensitive = true; // Set list model onto list view // Dino.Application app = GLib.Application.get_default() as Dino.Application; @@ -143,6 +154,7 @@ public class ConversationViewController : Object { } public void unset_conversation() { + main_window.conversation_title_button.sensitive = false; main_window.conversation_window_title.title = ""; main_window.conversation_window_title.subtitle = ""; } diff --git a/main/src/ui/launcher_badge.vala b/main/src/ui/launcher_badge.vala new file mode 100644 index 00000000..aa07fbaa --- /dev/null +++ b/main/src/ui/launcher_badge.vala @@ -0,0 +1,118 @@ +using Gee; +using GLib; + +using Dino.Entities; + +namespace Dino.Ui { + +[DBus (name = "com.canonical.Unity.LauncherEntry")] +public class LauncherBadge : Object { + private const string APP_URI = "application://im.dino.Dino.desktop"; + private const string BUS_NAME = "com.canonical.Unity"; + private const string OBJECT_PATH = "/im/dino/Dino/LauncherEntry"; + + private StreamInteractor stream_interactor; + private DBusConnection connection; + private HashMap<Conversation, ulong> read_handler_ids = new HashMap<Conversation, ulong>(Conversation.hash_func, Conversation.equals_func); + private int64 unread_count = -1; + private uint object_registration_id; + private uint watcher_id; + private ulong conversation_activated_handler_id; + private ulong conversation_deactivated_handler_id; + private ulong new_item_handler_id; + private ulong setting_handler_id; + + public signal void update(string app_uri, HashTable<string, Variant> properties); + + public LauncherBadge(StreamInteractor stream_interactor) throws IOError { + this.stream_interactor = stream_interactor; + connection = Bus.get_sync(BusType.SESSION); + object_registration_id = connection.register_object(OBJECT_PATH, this); + + ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY); + conversation_activated_handler_id = conversation_manager.conversation_activated.connect(add_conversation); + conversation_deactivated_handler_id = conversation_manager.conversation_deactivated.connect(remove_conversation); + new_item_handler_id = stream_interactor.get_module(ContentItemStore.IDENTITY).new_item.connect(() => update_count()); + setting_handler_id = Dino.Application.get_default().settings.notify["notifications-badge"].connect(() => update_count(true)); + + foreach (Conversation conversation in conversation_manager.get_active_conversations()) { + connect_conversation(conversation); + } + + watcher_id = Bus.watch_name_on_connection( + connection, + BUS_NAME, + BusNameWatcherFlags.NONE, + () => update_count(true)); + update_count(true); + } + + public void query(out string app_uri, out HashTable<string, Variant> properties) throws Error { + app_uri = APP_URI; + properties = get_properties(); + } + + internal void shutdown() { + unread_count = 0; + update(APP_URI, get_properties()); + + ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY); + conversation_manager.disconnect(conversation_activated_handler_id); + conversation_manager.disconnect(conversation_deactivated_handler_id); + stream_interactor.get_module(ContentItemStore.IDENTITY).disconnect(new_item_handler_id); + Dino.Application.get_default().settings.disconnect(setting_handler_id); + + foreach (Conversation conversation in read_handler_ids.keys) { + conversation.disconnect(read_handler_ids[conversation]); + } + read_handler_ids.clear(); + + if (watcher_id != 0) { + Bus.unwatch_name(watcher_id); + watcher_id = 0; + } + if (object_registration_id != 0) { + connection.unregister_object(object_registration_id); + object_registration_id = 0; + } + } + + private void add_conversation(Conversation conversation) { + connect_conversation(conversation); + update_count(); + } + + private void remove_conversation(Conversation conversation) { + if (read_handler_ids.has_key(conversation)) { + conversation.disconnect(read_handler_ids[conversation]); + read_handler_ids.unset(conversation); + } + update_count(); + } + + private void connect_conversation(Conversation conversation) { + if (read_handler_ids.has_key(conversation)) return; + read_handler_ids[conversation] = conversation.notify["read-up-to-item"].connect(() => update_count()); + } + + private void update_count(bool force = false) { + int64 count = 0; + ChatInteraction chat_interaction = stream_interactor.get_module(ChatInteraction.IDENTITY); + foreach (Conversation conversation in stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations()) { + count += chat_interaction.get_num_unread(conversation); + } + + if (!force && count == unread_count) return; + unread_count = count; + update(APP_URI, get_properties()); + } + + private HashTable<string, Variant> get_properties() { + HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal); + properties["count"] = new Variant.int64(unread_count); + properties["count-visible"] = new Variant.boolean(Dino.Application.get_default().settings.notifications_badge && unread_count > 0); + return properties; + } +} + +} diff --git a/main/src/ui/main_window.vala b/main/src/ui/main_window.vala index da6960ea..fc18d2bd 100644 --- a/main/src/ui/main_window.vala +++ b/main/src/ui/main_window.vala @@ -7,17 +7,19 @@ using Dino.Entities; namespace Dino.Ui { [GtkTemplate (ui = "/im/dino/Dino/main_window.ui")] -public class MainWindow : Adw.ApplicationWindow { +public class MainWindow : Gtk.ApplicationWindow { public signal void conversation_selected(Conversation conversation); [GtkChild] public unowned Stack stack; [GtkChild] public unowned Adw.NavigationSplitView navigation_split_view; - [GtkChild] public unowned MenuButton add_button; + [GtkChild] public unowned SearchEntry conversation_search_entry; [GtkChild] public unowned MenuButton menu_button; + [GtkChild] private unowned Box conversation_list_toolbar; [GtkChild] public unowned Adw.HeaderBar conversation_headerbar; + [GtkChild] public unowned Button conversation_title_button; [GtkChild] public unowned Adw.WindowTitle conversation_window_title; [GtkChild] public unowned ConversationView conversation_view; @@ -56,15 +58,25 @@ public class MainWindow : Adw.ApplicationWindow { this.add_css_class("devel"); } + var header_size_group = new SizeGroup(SizeGroupMode.VERTICAL); + header_size_group.add_widget(conversation_list_toolbar); + header_size_group.add_widget(conversation_headerbar); + ((Widget)this).realize.connect(restore_window_size); conversation_selector.init(stream_interactor); conversation_selector.conversation_selected.connect_after(() => { navigation_split_view.show_content = true; }); + conversation_search_entry.search_changed.connect(() => { + conversation_selector.set_search_text(conversation_search_entry.text); + }); + conversation_search_entry.stop_search.connect(() => { + conversation_search_entry.text = ""; + }); global_search = new GlobalSearch(stream_interactor); search_frame.set_child(global_search.get_widget()); - create_add_menu(add_button, menu_button); + create_menu(menu_button); stack.add_named(welcome_placeholder, "welcome_placeholder"); stack.add_named(accounts_placeholder, "accounts_placeholder"); @@ -137,13 +149,8 @@ public class MainWindow : Adw.ApplicationWindow { } } - private static void create_add_menu(MenuButton add_button, MenuButton menu_button) { - add_button.tooltip_text = Util.string_if_tooltips_active(_("Start Conversation")); - - Builder add_builder = new Builder.from_resource("/im/dino/Dino/menu_add.ui"); - MenuModel add_menu_model = add_builder.get_object("menu_add") as MenuModel; - add_button.set_menu_model(add_menu_model); - + private static void create_menu(MenuButton menu_button) { + menu_button.tooltip_text = Util.string_if_tooltips_active(_("Main Menu")); Builder menu_builder = new Builder.from_resource("/im/dino/Dino/menu_app.ui"); MenuModel menu_menu_model = menu_builder.get_object("menu_app") as MenuModel; menu_button.set_menu_model(menu_menu_model); diff --git a/main/src/ui/notifier_freedesktop.vala b/main/src/ui/notifier_freedesktop.vala index 99428315..3c8bee1c 100644 --- a/main/src/ui/notifier_freedesktop.vala +++ b/main/src/ui/notifier_freedesktop.vala @@ -11,10 +11,12 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { private DBusNotifications dbus_notifications; private bool supports_body_markup = false; private bool supports_body_hyperlinks = false; + private bool supports_inline_reply = false; private HashMap<Conversation, uint32> content_notifications = new HashMap<Conversation, uint32>(Conversation.hash_func, Conversation.equals_func); private HashMap<Conversation, Gee.List<uint32>> conversation_notifications = new HashMap<Conversation, Gee.List<uint32>>(Conversation.hash_func, Conversation.equals_func); private HashMap<uint32, HashMap<string, ListenerFuncWrapper>> action_listeners = new HashMap<uint32, HashMap<string, ListenerFuncWrapper>>(); + private HashMap<uint32, NotificationReplyTarget> reply_targets = new HashMap<uint32, NotificationReplyTarget>(); private HashMap<Call, uint32> call_notifications = new HashMap<Call, uint32>(Call.hash_func, Call.equals_func); public FreeDesktopNotifier(StreamInteractor stream_interactor, DBusNotifications dbus_notifications) { @@ -35,6 +37,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { case "body-hyperlinks": supports_body_hyperlinks = true; break; + case "inline-reply": + supports_inline_reply = true; + break; } } @@ -46,6 +51,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { dbus_notifications.notification_closed.connect((id) => { action_listeners.unset(id); + reply_targets.unset(id); + }); + + dbus_notifications.notification_replied.connect((id, text) => { + if (!reply_targets.has_key(id)) return; + + NotificationReplyTarget target = reply_targets[id]; + if (text.strip() != "") { + Dino.send_message(target.conversation, text, target.content_item_id, null, new ArrayList<Xep.MessageMarkup.Span>()); + } + + reply_targets.unset(id); + if (content_notifications.has_key(target.conversation) && content_notifications[target.conversation] == id) { + content_notifications.unset(target.conversation); + } + dbus_notifications.close_notification.begin(id); }); } @@ -62,7 +83,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } else { body = message.body; } - yield notify_content_item(conversation, conversation_display_name, participant_display_name, body); + ContentItem? reply_to_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_content_item_for_message(conversation, message); + int reply_to_id = reply_to_item != null ? reply_to_item.id : 0; + yield notify_content_item(conversation, conversation_display_name, participant_display_name, body, reply_to_id); } public async void notify_file(FileTransfer file_transfer, Conversation conversation, bool is_image, string conversation_display_name, string? participant_display_name) { @@ -77,10 +100,10 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { text = "<i>" + text + "</i>"; } - yield notify_content_item(conversation, conversation_display_name, participant_display_name, text); + yield notify_content_item(conversation, conversation_display_name, participant_display_name, text, 0); } - private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_) { + private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_, int reply_to_id) { string body = body_; if (participant_display_name != null) { if (supports_body_markup) { @@ -96,10 +119,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { hash_table["desktop-entry"] = new Variant.string(Dino.Application.get_default().get_application_id()); hash_table["category"] = new Variant.string("im.received"); string[] actions = new string[] {"default", "Open conversation"}; + if (reply_to_id != 0 && supports_inline_reply) { + actions += "inline-reply"; + actions += _("Reply"); + hash_table["x-kde-reply-placeholder-text"] = new Variant.string(_("Reply")); + hash_table["x-kde-reply-submit-button-text"] = new Variant.string(_("Send")); + } try { uint32 notification_id = yield dbus_notifications.notify("Dino", replace_id, "", conversation_display_name, body, actions, hash_table, -1); content_notifications[conversation] = notification_id; + if (reply_to_id != 0 && supports_inline_reply) { + reply_targets[notification_id] = new NotificationReplyTarget(conversation, reply_to_id); + } else { + reply_targets.unset(notification_id); + } + add_action_listener(notification_id, "default", () => { GLib.Application.get_default().activate_action("open-conversation", new Variant.int32(conversation.id)); }); @@ -284,12 +319,15 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } catch (Error e) { } } content_notifications.clear(); + reply_targets.clear(); } public async void retract_conversation_notifications(Conversation conversation) { try { if (content_notifications.has_key(conversation)) { - dbus_notifications.close_notification.begin(content_notifications[conversation]); + uint32 notification_id = content_notifications[conversation]; + dbus_notifications.close_notification.begin(notification_id); + reply_targets.unset(notification_id); content_notifications.unset(conversation); } @@ -323,6 +361,17 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } delegate void ListenerFunc(); + + class NotificationReplyTarget { + public Conversation conversation; + public int content_item_id; + + public NotificationReplyTarget(Conversation conversation, int content_item_id) { + this.conversation = conversation; + this.content_item_id = content_item_id; + } + } + class ListenerFuncWrapper { public ListenerFunc func; diff --git a/main/src/ui/status_notifier_item.vala b/main/src/ui/status_notifier_item.vala new file mode 100644 index 00000000..70db17d5 --- /dev/null +++ b/main/src/ui/status_notifier_item.vala @@ -0,0 +1,212 @@ +using GLib; + +namespace Dino.Ui { + +public struct StatusNotifierIconPixmap { + public int width; + public int height; + public uint8[] data; +} + +public struct StatusNotifierToolTip { + public string icon_name; + public StatusNotifierIconPixmap[] icon_pixmap; + public string title; + public string description; +} + +[DBus (name = "org.kde.StatusNotifierItem")] +public class StatusNotifierItem : Object { + private const string BUS_NAME_TEMPLATE = "org.kde.StatusNotifierItem-%d-1"; + private const string ICON_NAME = "im.dino.Dino"; + private const string ITEM_OBJECT_PATH = "/StatusNotifierItem"; + private const string MENU_OBJECT_PATH = "/MenuBar"; + private const string WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher"; + private const string WATCHER_INTERFACE = "org.kde.StatusNotifierWatcher"; + private const string WATCHER_OBJECT_PATH = "/StatusNotifierWatcher"; + + private weak Application application; + private DBusConnection connection; + private string bus_name; + private bool name_acquired; + private bool registration_in_progress; + private bool shutting_down; + private bool watcher_available; + private bool registered_with_watcher; + private string? activation_token; + private string? watcher_owner; + private Cancellable registration_cancellable = new Cancellable(); + private StatusNotifierMenu status_notifier_menu; + private uint bus_name_owner_id; + private uint menu_registration_id; + private uint object_registration_id; + private uint watcher_id; + + public string category { owned get { return "Communications"; } } + public string id { owned get { return "Dino"; } } + public string title { owned get { return "Dino"; } } + public string status { owned get { return "Active"; } } + public int window_id { get { return 0; } } + public string icon_theme_path { owned get { return ""; } } + public ObjectPath menu { owned get { return new ObjectPath(MENU_OBJECT_PATH); } } + public bool item_is_menu { get { return false; } } + public string icon_name { owned get { return ICON_NAME; } } + public StatusNotifierIconPixmap[] icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string overlay_icon_name { owned get { return ""; } } + public StatusNotifierIconPixmap[] overlay_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string attention_icon_name { owned get { return ""; } } + public StatusNotifierIconPixmap[] attention_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string attention_movie_name { owned get { return ""; } } + public StatusNotifierToolTip tool_tip { + owned get { + StatusNotifierToolTip tool_tip = StatusNotifierToolTip(); + tool_tip.icon_name = ICON_NAME; + tool_tip.icon_pixmap = new StatusNotifierIconPixmap[0]; + tool_tip.title = "Dino"; + tool_tip.description = _("Open Dino"); + return tool_tip; + } + } + + public signal void new_title(); + public signal void new_icon(); + public signal void new_attention_icon(); + public signal void new_overlay_icon(); + public signal void new_menu(); + public signal void new_tool_tip(); + public signal void new_status(string status); + + public StatusNotifierItem(Application application) throws IOError { + this.application = application; + this.connection = Bus.get_sync(BusType.SESSION); + this.bus_name = BUS_NAME_TEMPLATE.printf((int) Posix.getpid()); + + status_notifier_menu = new StatusNotifierMenu(application); + menu_registration_id = connection.register_object(MENU_OBJECT_PATH, status_notifier_menu); + object_registration_id = connection.register_object(ITEM_OBJECT_PATH, this); + bus_name_owner_id = Bus.own_name_on_connection( + connection, + bus_name, + BusNameOwnerFlags.NONE, + () => { + name_acquired = true; + register_with_watcher.begin(); + }, + () => { + name_acquired = false; + registered_with_watcher = false; + }); + watcher_id = Bus.watch_name_on_connection( + connection, + WATCHER_BUS_NAME, + BusNameWatcherFlags.NONE, + (watcher_connection, watcher_name, name_owner) => { + watcher_owner = name_owner; + watcher_available = true; + registered_with_watcher = false; + register_with_watcher.begin(); + }, + () => { + watcher_owner = null; + watcher_available = false; + registered_with_watcher = false; + }); + } + + internal void shutdown() { + shutting_down = true; + name_acquired = false; + watcher_available = false; + registration_cancellable.cancel(); + + if (watcher_id != 0) { + Bus.unwatch_name(watcher_id); + watcher_id = 0; + } + if (bus_name_owner_id != 0) { + Bus.unown_name(bus_name_owner_id); + bus_name_owner_id = 0; + } + if (object_registration_id != 0) { + connection.unregister_object(object_registration_id); + object_registration_id = 0; + } + if (menu_registration_id != 0) { + connection.unregister_object(menu_registration_id); + menu_registration_id = 0; + } + } + + public void provide_xdg_activation_token(string token) throws Error { + activation_token = token; + } + + public void context_menu(int x, int y) throws Error { + } + + public void activate(int x, int y) throws Error { + if (application.window != null && application.window.is_active) { + application.window.visible = false; + activation_token = null; + return; + } + + show_window(); + } + + public void secondary_activate(int x, int y) throws Error { + show_window(); + } + + public void scroll(int delta, string orientation) throws Error { + } + + private async void register_with_watcher() { + if (shutting_down || !name_acquired || !watcher_available || + registered_with_watcher || registration_in_progress) return; + + registration_in_progress = true; + string? registering_watcher = watcher_owner; + + try { + yield connection.call( + WATCHER_BUS_NAME, + WATCHER_OBJECT_PATH, + WATCHER_INTERFACE, + "RegisterStatusNotifierItem", + new Variant("(s)", bus_name), + null, + DBusCallFlags.NONE, + 5000, + registration_cancellable); + if (!shutting_down && name_acquired && watcher_owner == registering_watcher) { + registered_with_watcher = true; + } + } catch (Error e) { + if (!(e is IOError.CANCELLED)) { + warning("Could not register Dino's tray icon: %s", e.message); + } + } + + registration_in_progress = false; + if (!shutting_down && name_acquired && watcher_available && !registered_with_watcher && + watcher_owner != registering_watcher) { + register_with_watcher.begin(); + } + } + + private void show_window() { + if (application.window == null) { + application.activate(); + return; + } + + if (activation_token != null) { + application.window.set_startup_id(activation_token); + activation_token = null; + } + application.window.present(); + } +} + +} diff --git a/main/src/ui/status_notifier_menu.vala b/main/src/ui/status_notifier_menu.vala new file mode 100644 index 00000000..cc8b1375 --- /dev/null +++ b/main/src/ui/status_notifier_menu.vala @@ -0,0 +1,100 @@ +using GLib; + +namespace Dino.Ui { + +public struct DBusMenuLayout { + public int id; + public HashTable<string, Variant> properties; + public Variant[] children; +} + +[DBus (name = "com.canonical.dbusmenu")] +public class StatusNotifierMenu : Object { + private const int ROOT_ITEM_ID = 0; + private const int QUIT_ITEM_ID = 1; + + private weak Application application; + private bool quit_scheduled; + + public uint version { get { return 3; } } + public string text_direction { owned get { return "ltr"; } } + public string status { owned get { return "normal"; } } + public string[] icon_theme_path { owned get { return new string[0]; } } + + public signal void layout_updated(uint revision, int parent); + + public StatusNotifierMenu(Application application) { + this.application = application; + } + + public void get_layout(int parent_id, int recursion_depth, string[] property_names, + out uint revision, out DBusMenuLayout layout) throws Error { + revision = 1; + layout = create_layout(parent_id, recursion_depth, property_names); + } + + public void @event(int id, string event_id, Variant data, uint timestamp) throws Error { + if (id != QUIT_ITEM_ID || event_id != "clicked" || quit_scheduled) return; + + quit_scheduled = true; + Idle.add(() => { + application.quit(); + return Source.REMOVE; + }); + } + + public bool about_to_show(int id) throws Error { + return false; + } + + private DBusMenuLayout create_layout(int id, int recursion_depth, string[] property_names) { + DBusMenuLayout layout = DBusMenuLayout(); + layout.id = id; + layout.properties = get_item_properties(id, property_names); + layout.children = new Variant[0]; + + if (id == ROOT_ITEM_ID && recursion_depth != 0) { + DBusMenuLayout quit_item = create_layout(QUIT_ITEM_ID, 0, property_names); + layout.children = new Variant[] { layout_to_variant(quit_item) }; + } + return layout; + } + + private HashTable<string, Variant> get_item_properties(int id, string[] property_names) { + HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal); + + if (id == ROOT_ITEM_ID) { + add_property(properties, property_names, "children-display", new Variant.string("submenu")); + } else if (id == QUIT_ITEM_ID) { + add_property(properties, property_names, "label", new Variant.string(_("Quit Dino"))); + } + return properties; + } + + private static void add_property(HashTable<string, Variant> properties, string[] property_names, + string name, Variant value) { + if (property_names.length == 0 || name in property_names) { + properties.insert(name, value); + } + } + + private static Variant layout_to_variant(DBusMenuLayout layout) { + VariantBuilder properties = new VariantBuilder(new VariantType("a{sv}")); + layout.properties.foreach((name, value) => { + properties.add("{sv}", name, value); + }); + + Variant[] children = new Variant[layout.children.length]; + for (int i = 0; i < layout.children.length; i++) { + children[i] = new Variant.variant(layout.children[i]); + } + + return new Variant.tuple(new Variant[] { + new Variant.int32(layout.id), + properties.end(), + new Variant.array(VariantType.VARIANT, children) + }); + } +} + +} diff --git a/main/src/ui/widgets/avatar_picture.vala b/main/src/ui/widgets/avatar_picture.vala index d22f9cfe..b4636f49 100644 --- a/main/src/ui/widgets/avatar_picture.vala +++ b/main/src/ui/widgets/avatar_picture.vala @@ -272,6 +272,13 @@ public class Dino.Ui.CompatAvatarDrawer { public int width_request { get; set; default = 35; } public string font_family { get; set; default = "Sans"; } + public CompatAvatarDrawer() { + Dino.Application? application = Dino.Application.get_default(); + if (application != null && application.settings != null) { + radius_percent = (float) application.settings.avatar_roundness / 100.0f; + } + } + public Cairo.ImageSurface draw_image_surface() { Cairo.ImageSurface surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, width_request, height_request); draw_on_context(new Cairo.Context(surface)); @@ -392,12 +399,17 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget { private ViewModel.AvatarPictureModel? old_model; private ulong model_tiles_items_changed_handler; + private Binding? avatar_roundness_binding; construct { height_request = 35; width_request = 35; set_css_name("picture"); add_css_class("avatar"); + Dino.Application? application = Dino.Application.get_default(); + if (application != null && application.settings != null) { + avatar_roundness_binding = application.settings.bind_property("avatar-roundness", this, "radius-percent", BindingFlags.SYNC_CREATE, avatar_roundness_to_radius_percent); + } notify["radius-percent"].connect(queue_draw); notify["model"].connect(on_model_changed); check_widget_leak(this); @@ -488,11 +500,20 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget { } public override void dispose() { + if (avatar_roundness_binding != null) { + avatar_roundness_binding.unbind(); + avatar_roundness_binding = null; + } model = null; on_model_changed(); base.dispose(); } + private static bool avatar_roundness_to_radius_percent(Binding binding, Value from_value, ref Value to_value) { + to_value.set_float((float) from_value.get_int() / 100.0f); + return true; + } + private class Tile : Gtk.Widget { public ViewModel.AvatarPictureTileModel? model { get; set; } public Gdk.RGBA background_color { get; set; default = Gdk.RGBA(){ red = 1.0f, green = 1.0f, blue = 1.0f, alpha = 0.0f }; } |
