/* ide-frame-header.c * * Copyright 2016-2019 Christian Hergert * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * SPDX-License-Identifier: GPL-3.0-or-later */ #define G_LOG_DOMAIN "ide-frame-header" #include "config.h" #include #include "ide-gui-private.h" #include "ide-frame-header.h" #define CSS_PROVIDER_PRIORITY (GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 100) /** * SECTION:ide-frame-header * @title: IdeFrameHeader * @short_description: The header above document stacks * * The IdeFrameHeader is the titlebar widget above stacks of documents. * It is used to add state when a given document is in view. * * It can also track the primary color of the content and update it's * styling to match. * * Since: 3.32 */ struct _IdeFrameHeader { DzlPriorityBox parent_instance; GtkCssProvider *css_provider; guint update_css_handler; GdkRGBA background_rgba; GdkRGBA foreground_rgba; guint background_rgba_set : 1; guint foreground_rgba_set : 1; GtkButton *close_button; DzlMenuButton *document_button; GtkMenuButton *title_button; GtkPopover *title_popover; GtkListBox *title_list_box; DzlPriorityBox *title_box; GtkLabel *title_label; GtkLabel *title_modified; GtkBox *title_views_box; DzlJoinedMenu *menu; }; enum { PROP_0, PROP_BACKGROUND_RGBA, PROP_FOREGROUND_RGBA, PROP_MODIFIED, PROP_SHOW_CLOSE_BUTTON, PROP_TITLE, N_PROPS }; G_DEFINE_FINAL_TYPE (IdeFrameHeader, ide_frame_header, DZL_TYPE_PRIORITY_BOX) static GParamSpec *properties [N_PROPS]; void _ide_frame_header_focus_list (IdeFrameHeader *self) { g_return_if_fail (IDE_IS_FRAME_HEADER (self)); gtk_popover_popup (self->title_popover); gtk_widget_grab_focus (GTK_WIDGET (self->title_list_box)); } void _ide_frame_header_hide (IdeFrameHeader *self) { GtkPopover *popover; g_return_if_fail (IDE_IS_FRAME_HEADER (self)); /* This is like _ide_frame_header_popdown() but we hide the * popovers immediately without performing the popdown animation. */ popover = gtk_menu_button_get_popover (GTK_MENU_BUTTON (self->document_button)); if (popover != NULL) gtk_widget_hide (GTK_WIDGET (popover)); gtk_widget_hide (GTK_WIDGET (self->title_popover)); } void _ide_frame_header_popdown (IdeFrameHeader *self) { GtkPopover *popover; g_return_if_fail (IDE_IS_FRAME_HEADER (self)); popover = gtk_menu_button_get_popover (GTK_MENU_BUTTON (self->document_button)); if (popover != NULL) gtk_popover_popdown (popover); gtk_popover_popdown (self->title_popover); } void _ide_frame_header_update (IdeFrameHeader *self, IdePage *view) { const gchar *action = "frame.close-page"; g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (!view || IDE_IS_PAGE (view)); /* * Update our menus for the document to include the menu type needed for the * newly focused view. Make sure we keep the Frame section at the end which * is always the last section in the joined menus. */ while (dzl_joined_menu_get_n_joined (self->menu) > 1) dzl_joined_menu_remove_index (self->menu, 0); if (view != NULL) { const gchar *menu_id = ide_page_get_menu_id (view); if (menu_id != NULL) { GMenu *menu = dzl_application_get_menu_by_id (DZL_APPLICATION_DEFAULT, menu_id); dzl_joined_menu_prepend_menu (self->menu, G_MENU_MODEL (menu)); } } /* * Hide the document selectors if there are no views to select (which is * indicated by us having a NULL view here. */ gtk_widget_set_visible (GTK_WIDGET (self->title_views_box), view != NULL); /* * The close button acts differently depending on the grid stage. * * - Last column, single stack => do nothing (action will be disabled) * - No more views and more than one stack in column (close just the stack) * - No more views and single stack in column and more than one column (close the column) */ if (view == NULL) { GtkWidget *stack; GtkWidget *column; action = "gridcolumn.close"; stack = gtk_widget_get_ancestor (GTK_WIDGET (self), IDE_TYPE_FRAME); column = gtk_widget_get_ancestor (GTK_WIDGET (stack), IDE_TYPE_GRID_COLUMN); if (stack != NULL && column != NULL) { if (dzl_multi_paned_get_n_children (DZL_MULTI_PANED (column)) > 1) action = "frame.close-stack"; } } gtk_actionable_set_action_name (GTK_ACTIONABLE (self->close_button), action); /* * Hide any popovers that we know about. If we got here from closing * documents, we should hide the popover after the last document is closed * (inidicated by NULL view). */ if (view == NULL) _ide_frame_header_popdown (self); } static void close_view_cb (GtkButton *button, IdeFrameHeader *self) { GtkWidget *stack; GtkWidget *row; GtkWidget *view; g_assert (GTK_IS_BUTTON (button)); g_assert (IDE_IS_FRAME_HEADER (self)); row = gtk_widget_get_ancestor (GTK_WIDGET (button), GTK_TYPE_LIST_BOX_ROW); if (row == NULL) return; view = g_object_get_data (G_OBJECT (row), "IDE_PAGE"); if (view == NULL) return; stack = gtk_widget_get_ancestor (GTK_WIDGET (self), IDE_TYPE_FRAME); if (stack == NULL) return; _ide_frame_request_close (IDE_FRAME (stack), IDE_PAGE (view)); } static gboolean modified_to_attrs (GBinding *binding, const GValue *src_value, GValue *dst_value, gpointer user_data) { PangoAttrList *attrs = NULL; if (g_value_get_boolean (src_value)) { attrs = pango_attr_list_new (); pango_attr_list_insert (attrs, pango_attr_style_new (PANGO_STYLE_ITALIC)); } g_value_take_boxed (dst_value, attrs); return TRUE; } static GtkWidget * create_document_row (gpointer item, gpointer user_data) { IdeFrameHeader *self = user_data; GtkListBoxRow *row; GtkButton *close_button; GtkLabel *label; GtkImage *image; GtkBox *box; g_assert (IDE_IS_PAGE (item)); g_assert (IDE_IS_FRAME_HEADER (self)); row = g_object_new (GTK_TYPE_LIST_BOX_ROW, "visible", TRUE, NULL); box = g_object_new (GTK_TYPE_BOX, "visible", TRUE, NULL); image = g_object_new (GTK_TYPE_IMAGE, "icon-size", GTK_ICON_SIZE_MENU, "visible", TRUE, NULL); label = g_object_new (DZL_TYPE_BOLDING_LABEL, "hexpand", TRUE, "xalign", 0.0f, "visible", TRUE, NULL); close_button = g_object_new (GTK_TYPE_BUTTON, "child", g_object_new (GTK_TYPE_IMAGE, "icon-name", "window-close-symbolic", "visible", TRUE, NULL), "valign", GTK_ALIGN_CENTER, "visible", TRUE, NULL); g_signal_connect_object (close_button, "clicked", G_CALLBACK (close_view_cb), self, 0); dzl_gtk_widget_add_style_class (GTK_WIDGET (close_button), "image-button"); g_object_bind_property (item, "icon", image, "gicon", G_BINDING_SYNC_CREATE); g_object_bind_property_full (item, "modified", label, "attributes", G_BINDING_SYNC_CREATE, modified_to_attrs, NULL, NULL, NULL); g_object_bind_property (item, "title", label, "label", G_BINDING_SYNC_CREATE); g_object_set_data (G_OBJECT (row), "IDE_PAGE", item); gtk_container_add (GTK_CONTAINER (row), GTK_WIDGET (box)); gtk_container_add (GTK_CONTAINER (box), GTK_WIDGET (image)); gtk_container_add (GTK_CONTAINER (box), GTK_WIDGET (label)); gtk_container_add (GTK_CONTAINER (box), GTK_WIDGET (close_button)); return GTK_WIDGET (row); } static void ide_frame_header_model_changed (IdeFrameHeader *self, guint position, guint removed, guint added, GListModel *model) { guint size; g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (G_IS_LIST_MODEL (model)); size = g_list_model_get_n_items (model); gtk_widget_set_sensitive (GTK_WIDGET (self->title_button), size > 0); } void _ide_frame_header_set_pages (IdeFrameHeader *self, GListModel *model) { g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (!model || G_IS_LIST_MODEL (model)); gtk_list_box_bind_model (self->title_list_box, model, create_document_row, self, NULL); /* We need to watch our model for any new document added or removed */ g_signal_connect_object (model, "items-changed", G_CALLBACK (ide_frame_header_model_changed), self, G_CONNECT_SWAPPED); } static void ide_frame_header_view_row_activated (GtkListBox *list_box, GtkListBoxRow *row, IdeFrameHeader *self) { GtkWidget *stack; GtkWidget *page; g_assert (GTK_IS_LIST_BOX (list_box)); g_assert (GTK_IS_LIST_BOX_ROW (row)); g_assert (IDE_IS_FRAME_HEADER (self)); stack = gtk_widget_get_ancestor (GTK_WIDGET (self), IDE_TYPE_FRAME); page = g_object_get_data (G_OBJECT (row), "IDE_PAGE"); if (stack != NULL && page != NULL) { ide_frame_set_visible_child (IDE_FRAME (stack), IDE_PAGE (page)); gtk_widget_grab_focus (page); } _ide_frame_header_popdown (self); } static gboolean ide_frame_header_update_css (IdeFrameHeader *self) { g_autoptr(GString) str = NULL; g_autoptr(GError) error = NULL; g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (self->css_provider != NULL); g_assert (GTK_IS_CSS_PROVIDER (self->css_provider)); str = g_string_new (NULL); /* * We set various styles on this provider so that we can update multiple * widgets using the same CSS style. That includes ourself, various buttons, * labels, and some images. */ if (self->background_rgba_set) { g_autofree gchar *bgstr = gdk_rgba_to_string (&self->background_rgba); g_string_append (str, "ideframeheader {\n"); g_string_append (str, " background: none;\n"); g_string_append_printf (str, " background-color: %s;\n", bgstr); g_string_append (str, " transition: background-color 400ms;\n"); g_string_append (str, " transition-timing-function: ease;\n"); g_string_append_printf (str, " border-bottom: 1px solid shade(%s,0.9);\n", bgstr); g_string_append (str, " }\n"); g_string_append (str, "button { background: transparent; }\n"); g_string_append (str, "button:hover, button:checked {\n"); g_string_append_printf (str, " background: none; background-color: shade(%s,.85); }\n", bgstr); /* only use foreground when background is set */ if (self->foreground_rgba_set) { static const gchar *names[] = { "image", "label" }; g_autofree gchar *fgstr = gdk_rgba_to_string (&self->foreground_rgba); for (guint i = 0; i < G_N_ELEMENTS (names); i++) { g_string_append_printf (str, "%s { ", names[i]); g_string_append (str, " -gtk-icon-shadow: none;\n"); g_string_append (str, " text-shadow: none;\n"); g_string_append_printf (str, " text-shadow: 0 -1px alpha(%s,0.05);\n", fgstr); g_string_append_printf (str, " color: %s;\n", fgstr); g_string_append (str, "}\n"); } } } /* Use -1 for length so CSS provider knows the string is NULL terminated * and there-by avoid a string copy. */ if (!gtk_css_provider_load_from_data (self->css_provider, str->str, -1, &error)) g_warning ("Failed to load CSS: '%s': %s", str->str, error->message); self->update_css_handler = 0; return G_SOURCE_REMOVE; } static void ide_frame_header_queue_update_css (IdeFrameHeader *self) { g_assert (IDE_IS_FRAME_HEADER (self)); if (self->update_css_handler == 0) self->update_css_handler = gdk_threads_add_idle_full (G_PRIORITY_HIGH, (GSourceFunc) ide_frame_header_update_css, g_object_ref (self), g_object_unref); } void _ide_frame_header_set_background_rgba (IdeFrameHeader *self, const GdkRGBA *background_rgba) { GdkRGBA old; gboolean old_set; g_assert (IDE_IS_FRAME_HEADER (self)); old_set = self->background_rgba_set; old = self->background_rgba; if (background_rgba != NULL) self->background_rgba = *background_rgba; self->background_rgba_set = !!background_rgba; if (self->background_rgba_set != old_set || !gdk_rgba_equal (&self->background_rgba, &old)) ide_frame_header_queue_update_css (self); } void _ide_frame_header_set_foreground_rgba (IdeFrameHeader *self, const GdkRGBA *foreground_rgba) { GdkRGBA old; gboolean old_set; g_assert (IDE_IS_FRAME_HEADER (self)); old_set = self->foreground_rgba_set; old = self->foreground_rgba; if (foreground_rgba != NULL) self->foreground_rgba = *foreground_rgba; self->foreground_rgba_set = !!foreground_rgba; if (self->background_rgba_set != old_set || !gdk_rgba_equal (&self->foreground_rgba, &old)) ide_frame_header_queue_update_css (self); } static void update_widget_providers (GtkWidget *widget, IdeFrameHeader *self) { g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (GTK_IS_WIDGET (widget)); /* * The goal here is to explore the widget hierarchy a bit to find widget * types that we care about styling. This is the second half to our CSS * strategy to assign specific CSS providers to widgets instead of a global * CSS provider. The goal here is to avoid the giant CSS invalidation that * happens when invalidating the global CSS tree. */ if (GTK_IS_BUTTON (widget) || GTK_IS_LABEL (widget) || GTK_IS_IMAGE (widget) || DZL_IS_SIMPLE_LABEL (widget)) { GtkStyleContext *style_context; style_context = gtk_widget_get_style_context (widget); gtk_style_context_add_provider (style_context, GTK_STYLE_PROVIDER (self->css_provider), CSS_PROVIDER_PRIORITY); } if (GTK_IS_CONTAINER (widget)) gtk_container_foreach (GTK_CONTAINER (widget), (GtkCallback) update_widget_providers, self); } static void ide_frame_header_add (GtkContainer *container, GtkWidget *widget) { IdeFrameHeader *self = (IdeFrameHeader *)container; g_assert (IDE_IS_FRAME_HEADER (self)); g_assert (GTK_IS_WIDGET (widget)); GTK_CONTAINER_CLASS (ide_frame_header_parent_class)->add (container, widget); update_widget_providers (widget, self); } static void ide_frame_header_get_preferred_width (GtkWidget *widget, gint *min_width, gint *nat_width) { g_assert (IDE_IS_FRAME_HEADER (widget)); g_assert (min_width != NULL); g_assert (nat_width != NULL); GTK_WIDGET_CLASS (ide_frame_header_parent_class)->get_preferred_width (widget, min_width, nat_width); /* * We don't want changes to the natural width to influence our positioning of * the grid separators (unless necessary). So instead, we always return our * minimum position as our natural size and let the grid expand as necessary. */ *nat_width = *min_width; } static void ide_frame_header_destroy (GtkWidget *widget) { IdeFrameHeader *self = (IdeFrameHeader *)widget; g_assert (IDE_IS_FRAME_HEADER (self)); dzl_clear_source (&self->update_css_handler); g_clear_object (&self->css_provider); if (self->title_list_box != NULL) gtk_list_box_bind_model (self->title_list_box, NULL, NULL, NULL, NULL); g_clear_object (&self->menu); GTK_WIDGET_CLASS (ide_frame_header_parent_class)->destroy (widget); } static void ide_frame_header_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) { IdeFrameHeader *self = IDE_FRAME_HEADER (object); switch (prop_id) { case PROP_MODIFIED: g_value_set_boolean (value, gtk_widget_get_visible (GTK_WIDGET (self->title_modified))); break; case PROP_SHOW_CLOSE_BUTTON: g_value_set_boolean (value, gtk_widget_get_visible (GTK_WIDGET (self->close_button))); break; case PROP_TITLE: g_value_set_string (value, gtk_label_get_label (GTK_LABEL (self->title_label))); break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); } } static void ide_frame_header_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) { IdeFrameHeader *self = IDE_FRAME_HEADER (object); switch (prop_id) { case PROP_BACKGROUND_RGBA: _ide_frame_header_set_background_rgba (self, g_value_get_boxed (value)); break; case PROP_FOREGROUND_RGBA: _ide_frame_header_set_foreground_rgba (self, g_value_get_boxed (value)); break; case PROP_MODIFIED: _ide_frame_header_set_modified (self, g_value_get_boolean (value)); break; case PROP_SHOW_CLOSE_BUTTON: gtk_widget_set_visible (GTK_WIDGET (self->close_button), g_value_get_boolean (value)); break; case PROP_TITLE: _ide_frame_header_set_title (self, g_value_get_string (value)); break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); } } static void ide_frame_header_class_init (IdeFrameHeaderClass *klass) { GObjectClass *object_class = G_OBJECT_CLASS (klass); GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); GtkContainerClass *container_class = GTK_CONTAINER_CLASS (klass); object_class->get_property = ide_frame_header_get_property; object_class->set_property = ide_frame_header_set_property; widget_class->destroy = ide_frame_header_destroy; widget_class->get_preferred_width = ide_frame_header_get_preferred_width; container_class->add = ide_frame_header_add; /** * IdeFrameHeader:background-rgba: * * The "background-rgba" property can be used to set the background * color of the header. This should be set to the * #IdePage:primary-color of the active view. * * Set to %NULL to unset the primary-color. * * Since: 3.32 */ properties [PROP_BACKGROUND_RGBA] = g_param_spec_boxed ("background-rgba", "Background RGBA", "The background color to use for the header", GDK_TYPE_RGBA, (G_PARAM_WRITABLE | G_PARAM_STATIC_STRINGS)); /** * IdeFrameHeader:foreground-rgba: * * Sets the foreground color to use when * #IdeFrameHeader:background-rgba is used for the background. * * Since: 3.32 */ properties [PROP_FOREGROUND_RGBA] = g_param_spec_boxed ("foreground-rgba", "Foreground RGBA", "The foreground color to use with background-rgba", GDK_TYPE_RGBA, (G_PARAM_WRITABLE | G_PARAM_STATIC_STRINGS)); properties [PROP_SHOW_CLOSE_BUTTON] = g_param_spec_boolean ("show-close-button", "Show Close Button", "If the close button should be displayed", FALSE, (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); properties [PROP_MODIFIED] = g_param_spec_boolean ("modified", "Modified", "If the current document is modified", FALSE, (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS)); properties [PROP_TITLE] = g_param_spec_string ("title", "Title", "The title of the current document or view", NULL, (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS)); g_object_class_install_properties (object_class, N_PROPS, properties); gtk_widget_class_set_css_name (widget_class, "ideframeheader"); gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/libide-gui/ui/ide-frame-header.ui"); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, close_button); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, document_button); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_box); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_button); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_label); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_list_box); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_modified); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_popover); gtk_widget_class_bind_template_child (widget_class, IdeFrameHeader, title_views_box); } static void ide_frame_header_init (IdeFrameHeader *self) { GtkStyleContext *style_context; GMenu *frame_section; /* * To keep our foreground/background colors up to date, we use a CSS * provider. However, attaching the provider globally causes much CSS * style cascading exactly at the moment we want to animate. To avbid * that, and keep animations snappy, we add the provider directly to * our widget and to the children widgets we care about (buttons, their * labels, etc). */ style_context = gtk_widget_get_style_context (GTK_WIDGET (self)); self->css_provider = gtk_css_provider_new (); gtk_style_context_add_provider (style_context, GTK_STYLE_PROVIDER (self->css_provider), CSS_PROVIDER_PRIORITY); gtk_widget_init_template (GTK_WIDGET (self)); /* * Create our menu for the document controls popover. It has two sections. * The top section is based on the document and is updated whenever the * visible child is changed. The bottom, are the frame controls are and * static, but setup by us here. */ self->menu = dzl_joined_menu_new (); dzl_menu_button_set_model (self->document_button, G_MENU_MODEL (self->menu)); frame_section = dzl_application_get_menu_by_id (DZL_APPLICATION_DEFAULT, "ide-frame-menu"); dzl_joined_menu_append_menu (self->menu, G_MENU_MODEL (frame_section)); /* * When a row is selected, we want to change the current view and * hide the popover. */ g_signal_connect_object (self->title_list_box, "row-activated", G_CALLBACK (ide_frame_header_view_row_activated), self, 0); gtk_widget_set_sensitive (GTK_WIDGET (self->title_button), FALSE); G_GNUC_BEGIN_IGNORE_DEPRECATIONS; gtk_container_set_reallocate_redraws (GTK_CONTAINER (self), TRUE); G_GNUC_END_IGNORE_DEPRECATIONS; } GtkWidget * ide_frame_header_new (void) { return g_object_new (IDE_TYPE_FRAME_HEADER, NULL); } /** * ide_frame_header_add_custom_title: * @self: a #IdeFrameHeader * @widget: a #GtkWidget * @priority: the sort priority * * This will add @widget to the title area with @priority determining the * sort order of the child. * * All "title" widgets in the #IdeFrameHeader are expanded to the * same size. So if you don't need that, you should just use the normal * gtk_container_add_with_properties() API to specify your widget with * a given priority. * * Since: 3.32 */ void ide_frame_header_add_custom_title (IdeFrameHeader *self, GtkWidget *widget, gint priority) { g_return_if_fail (IDE_IS_FRAME_HEADER (self)); g_return_if_fail (GTK_IS_WIDGET (widget)); gtk_container_add_with_properties (GTK_CONTAINER (self->title_box), widget, "priority", priority, NULL); update_widget_providers (widget, self); } void _ide_frame_header_set_title (IdeFrameHeader *self, const gchar *title) { g_return_if_fail (IDE_IS_FRAME_HEADER (self)); gtk_label_set_label (GTK_LABEL (self->title_label), title); g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_TITLE]); } void _ide_frame_header_set_modified (IdeFrameHeader *self, gboolean modified) { PangoAttrList *attrs = NULL; g_return_if_fail (IDE_IS_FRAME_HEADER (self)); if (modified) { attrs = pango_attr_list_new (); pango_attr_list_insert (attrs, pango_attr_style_new (PANGO_STYLE_ITALIC)); } gtk_label_set_attributes (self->title_label, attrs); gtk_widget_set_visible (GTK_WIDGET (self->title_modified), modified); g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_MODIFIED]); g_clear_pointer (&attrs, pango_attr_list_unref); }