diff --git a/demo/Views/ListsView.vala b/demo/Views/ListsView.vala index f8421db7d..0707f5528 100644 --- a/demo/Views/ListsView.vala +++ b/demo/Views/ListsView.vala @@ -100,6 +100,81 @@ public class ListsView : DemoPage { min_content_height = 128 }; + var treelist_title = new Granite.HeaderLabel ("Granite.TreeList"); + var treelist = new Granite.TreeList () { + margin_start = 12, + margin_end = 12, + row_spacing = 12, + }; + + // We manually create basic treelistitems - normally these would be subclassed + // and programmatically created based on app data e.g. directory listing + // implementing additional data e.g. filepath + var root_item_expandable = treelist.add_root_item ( + new Granite.TreeListItem.expandable () { + text = "Folder 1.1", + tooltip = "First expandable item", + icon_name = "folder" + } + ); + + root_item_expandable.add_child ( + new Granite.TreeListItem () { + text = "Item 2.1 - image", + tooltip = "Non-expandable image item", + icon_name = "image-x-generic", + } + ); + root_item_expandable.add_child ( + new Granite.TreeListItem () { + text = "Item 2.2 - Vala", + tooltip = "Second non-expandable item", + icon_name = "text-x-vala", + } + ); + root_item_expandable.add_child ( + new Granite.TreeListItem.expandable () { + text = "Folder 2.3", + tooltip = "Expandable child - empty", + icon_name = "folder" + } + ); + + treelist.add_root_item ( + new Granite.TreeListItem () { + text = "Item 1.1 - generic text", + tooltip = "First non-expandable item", + icon_name = "text-x-generic", + badge = "1", + secondary_icon_name = "emblem-enabled", + secondary_icon_tooltip = "enabled" + + } + ); + treelist.add_root_item ( + new Granite.TreeListItem () { + text = "Item 1.2 - PDF", + tooltip = "First non-expandable item", + icon_name = "application-pdf", + badge = "2", + secondary_icon_name = "emblem-error" + } + ); + + var treelist_scrolled = new Gtk.ScrolledWindow () { + child = treelist, + has_frame = true, + hscrollbar_policy = NEVER, + min_content_height = 128 + }; + + var treelist_paned = new Gtk.Paned (HORIZONTAL) { + start_child = treelist_scrolled, + end_child = new Granite.Placeholder (""), + position = 250 + }; + + var grid_title = new Granite.HeaderLabel ("Gtk.GridView"); var grid_store = new GLib.ListStore (typeof (GridObject)); @@ -172,6 +247,8 @@ public class ListsView : DemoPage { vbox.append (list_box); vbox.append (list_title); vbox.append (list_scrolled); + vbox.append (treelist_title); + vbox.append (treelist_paned); vbox.append (grid_title); vbox.append (grid_scrolled); diff --git a/lib/Widgets/TreeList/TreeList.vala b/lib/Widgets/TreeList/TreeList.vala new file mode 100644 index 000000000..ce7af1ee4 --- /dev/null +++ b/lib/Widgets/TreeList/TreeList.vala @@ -0,0 +1,259 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 20126 elementary, Inc. + */ + +/** + * A standard widget for displaying a Gtk.TreeListModel suitable for directory + * structures. The model is expected to contain objects that implement the + * TreeListItem interface. + * + * The widget can display primary and secondary icons + * as well as a badge for each item. It the item is marked expandable then an + * expander is also displayed + * + * The TreeList Model is wrapped in a SingleSelection model. + * + * Activating an item results in an `item-activated` signal indicating the item activated + * Triggering a context menu on an item results in a `popup_context_menu` signal indicating + * both the item selected and its coordinates relative to the widget for positioning the menu. + * + * Root items appear at the first level of the tree. It is up to the user to + * implement a `0th` level widget, in any form, if needed, to provide a description, show/hide and + * other functionality + * + * @since 7.9.0 + */ + +[Version (since = "7.9.0")] +//TODO Should we allow subclassing? +public sealed class Granite.TreeList : Granite.Bin { + public signal void item_activated (TreeListItem item); + public signal void popup_context_menu ( + Graphene.Point view_point, + Granite.TreeListItem treelistitem + ); + + public bool activate_on_single_click { get; set; default = true;} + public int row_spacing { get; set; default = 0;} + + private Gtk.ListView list_view; + private GLib.ListStore root_model; + private Gtk.TreeListModel tree_model; + private Gtk.SingleSelection selection_model; + + construct { + root_model = new GLib.ListStore (typeof (TreeListItem)); + tree_model = new Gtk.TreeListModel ( + root_model, + false, // passthrough + false, // autoexpand + (obj) => { //create model for child + var data = (TreeListItem) obj; + // If the item needs a child model create it but do not populate + if (data.is_expandable && data.child_model == null) { + data.create_child_model (); + } + + return data.child_model; + } + ); + + selection_model = new Gtk.SingleSelection (tree_model); + var tree_list_factory = new Gtk.SignalListItemFactory (); + + list_view = new Gtk.ListView (selection_model, tree_list_factory); + + bind_property ( + "activate-on-single-click", + list_view, "single-click-activate", + BIDIRECTIONAL | SYNC_CREATE + ); + + list_view.activate.connect ((pos) => { + var tree_row = ((Gtk.TreeListRow) selection_model.get_item (pos)); + var data = (Granite.TreeListItem) (tree_row.item); + item_activated (data); + }); + + // LIST ITEM FACTORY HANDLERS + tree_list_factory.setup.connect ((obj) => { + var listitem = (Gtk.ListItem) obj; + create_listitem_child (listitem); + }); + tree_list_factory.teardown.connect ((obj) => { + var listitem = (Gtk.ListItem) obj; + teardown_listitem_child (listitem); + }); + tree_list_factory.bind.connect ((obj) => { + var listitem = (Gtk.ListItem) obj; + var treelistrow = (Gtk.TreeListRow) (listitem.get_item ()); + var data = (Granite.TreeListItem) (treelistrow.get_item ()); + bind_data_to_row (data, treelistrow, listitem); + }); + tree_list_factory.unbind.connect ((obj) => { + var listitem = (Gtk.ListItem) obj; + var treelistrow = (Gtk.TreeListRow) (listitem.item); + var data = (Granite.TreeListItem) (treelistrow.item); + unbind_data_from_row (data, treelistrow, listitem); + }); + + child = list_view; + } + + public TreeListItem add_root_item ( + TreeListItem item + ) { + root_model.append (item); + return item; + } + + public void remove_root_item (TreeListItem item) { + uint pos; + if (root_model.find (item, out pos)) { + root_model.remove (pos); + } + } + + public void remove_root_children (List to_remove) { + foreach (TreeListItem item in to_remove) { + uint pos; + if (root_model.find (item, out pos)) { + root_model.remove (pos); + } + } + } + + public void remove_all () { + root_model.remove_all (); + } + + public void sort_root_children (CompareDataFunc sort_func) { + root_model.sort (sort_func); + } + + public uint n_root_items () { + return root_model.get_n_items (); + } + + public delegate bool ListIteratorCallback (TreeListItem item); + public const bool ITERATE_CONTINUE = true; + public const bool ITERATE_STOP = false; + public void iterate_children (TreeListItem? start, ListIteratorCallback cb) { + ListModel model; + if (start == null) { + model = root_model; + } else { + model = start.child_model; + } + + TreeListItem? item = null; + uint pos = 0; + do { + item = (TreeListItem?) (model.get_object (pos++)); + } while (item != null && cb (item)); + } + + public void expand_all (TreeListItem? start) { + iterate_children (start, expand_callback); + } + + private bool expand_callback (TreeListItem item) { + if (item.is_expandable) { + iterate_children (item, expand_callback); + } + + return TreeList.ITERATE_CONTINUE; + } + + + public void unselect_all () { + selection_model.unselect_all (); + } + + private void create_listitem_child (Gtk.ListItem listitem) { + var label = new Gtk.Label ("") { + halign = START, + hexpand = true + }; + label.add_css_class (Granite.STYLE_CLASS_SMALL_LABEL); + var primary_image = new Gtk.Image.from_icon_name (null); + var secondary_image = new Gtk.Image.from_icon_name (null); + var badge_label = new Gtk.Label (""); + badge_label.add_css_class (Granite.STYLE_CLASS_SMALL_LABEL); + var half_spacing = row_spacing / 2; + var box = new Gtk.Box (HORIZONTAL, 6) { + hexpand = true, + margin_top = half_spacing, + margin_bottom = row_spacing - half_spacing + }; + box.append (primary_image); + box.append (label); + box.append (secondary_image); + box.append (badge_label); + + var expander = new Gtk.TreeExpander () { + child = box + }; + + listitem.child = expander; + + var button_controller = new Gtk.GestureClick () { + propagation_phase = CAPTURE, + button = 0 + }; + + box.add_controller (button_controller); + button_controller.pressed.connect ((n_press, bx, by) => { + if (!button_controller.get_last_event (null).triggers_context_menu ()) { + return; + } // Only true for press events + var treelistrow = (Gtk.TreeListRow) (listitem.get_item ()); + var data = (Granite.TreeListItem) (treelistrow.get_item ()); + var button_point = Graphene.Point () {x = (float) bx, y = (float) by}; + var view_point = Graphene.Point (); + listitem.get_child ().compute_point (list_view, button_point, out view_point); + popup_context_menu (view_point, data); + }); + } + + private void teardown_listitem_child (Gtk.ListItem item) { + // Must be paired with create_listitem child + // Assuming controller will be removed automatically on teardown + } + + private void bind_data_to_row ( + TreeListItem data, + Gtk.TreeListRow row, + Gtk.ListItem item + ) { + // Must be matched with create item widget when overriding + var expander = (Gtk.TreeExpander) (item.child); + expander.set_list_row (row); + expander.hide_expander = !data.is_expandable; + data.expanded_binding = data.bind_property ("is-expanded", row, "expanded", BIDIRECTIONAL | SYNC_CREATE); + var box = (Gtk.Box)(expander.child); + var primary_image = (Gtk.Image)(box.get_first_child ()); + var name_label = (Gtk.Label)(primary_image.get_next_sibling ()); + var secondary_image = (Gtk.Image) (name_label.get_next_sibling ()); + var badge_label = (Gtk.Label) secondary_image.get_next_sibling (); + + name_label.label = data.text; + name_label.tooltip_text = data.tooltip; + primary_image.icon_name = data.icon_name; + secondary_image.icon_name = data.secondary_icon_name; + secondary_image.tooltip_text = data.secondary_icon_tooltip; + badge_label.label = data.badge; + + //TODO Should we expose a public virtual method to allow users to modify + // the ListItem? + } + + private void unbind_data_from_row ( + TreeListItem data, + Gtk.TreeListRow row, + Gtk.ListItem item + ) { + data.expanded_binding.unbind (); + } + } diff --git a/lib/Widgets/TreeList/TreeListItem.vala b/lib/Widgets/TreeList/TreeListItem.vala new file mode 100644 index 000000000..c80001671 --- /dev/null +++ b/lib/Widgets/TreeList/TreeListItem.vala @@ -0,0 +1,134 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 20126 elementary, Inc. + */ + +// Subclass to provide additional functions required for project list, symbol list etc + +[Version (since = "7.7.0")] +public class Granite.TreeListItem : Object { + public signal void child_added (TreeListItem item); // To emulate source list + + public string text { get; set; default = ""; } //This can include markup + public string tooltip { get; set; default = ""; } + public string icon_name { get; set; default = null;} + public string? secondary_icon_name { get; set; default = null;} + public string secondary_icon_tooltip { get; set; default = ""; } + public string badge = ""; // Use label styled with Granite.STYLE_CLASS_BADGE? + + public ListStore? child_model { get; set; } + public TreeListItem? parent { get; set; default = null; } + + public bool is_expandable { get; set construct; } + public bool is_expanded { get; set; } // gets bound to the ListItem (temporarily) + public Binding expanded_binding { get; set; } // Need to save bind so we can unbind + public bool is_activatable { get; set; default = true; } + public bool is_selectable { get; set; default = true; } + public bool is_editable { get; set; default = false; } + public bool is_dummy { get; construct; } + public uint n_children { + get { + return child_model == null ? 0 : child_model.get_n_items (); + } + } + + public TreeListItem () { + Object ( + is_dummy: false + ); + } + + public TreeListItem.dummy () { + Object ( + is_dummy: true + ); + } + + public TreeListItem.expandable () { + Object ( + is_dummy: false, + is_expandable: true + ); + } + + public ListModel create_child_model () requires (is_expandable) { + child_model = new ListStore (typeof (TreeListItem)); + return child_model; + } + + public virtual void add_child (TreeListItem child) requires (is_expandable) { + if (child_model == null) { + create_child_model (); + } + + child_model.insert_sorted (child, (a, b) => { + return strcmp (((TreeListItem) a).text, ((TreeListItem) b).text); + }); + + child_added (child); + } + + public virtual void remove_child (TreeListItem child) requires (child_model != null) { + uint pos; + if (child_model.find (child, out pos)) { + child_model.remove (pos); + } + } + + public void remove_all_children () { + if (child_model != null) { + child_model.remove_all (); + } + } + + public bool has_no_children () requires (child_model != null) { + return child_model.n_items == 0; + } + + public TreeListItem? get_nth_child (uint pos) { + return (TreeListItem?) child_model.get_object (pos); + } + + /** + * Collapses the item and/or its children. + * + * @param inclusive Whether to also collapse this item (true), or only its children (false). + * @param recursive Whether to recursively collapse all the children (true), or only + * immediate children (false). + */ + public void collapse_all ( + bool inclusive, + bool recursive, + uint level = 0 + ) requires (child_model != null) { + TreeListItem? child = null; + uint pos = 0; + do { + child = (TreeListItem?) (child_model.get_object (pos++)); + if (child.is_expandable) { + if (recursive) { + child.collapse_all (true, true, level++); + } else { + child.is_expanded = false; + } + } + } while (child != null); + + if (level == 0 && inclusive) { + is_expanded = false; + } else { + level--; + } + } + + // Calls supplied callback for each child (not recursive) + // It is up to the user to implement recursion if required (see collapse all for example) + public delegate bool IterateChildrenCallback (Object obj); + public void iterate_children (IterateChildrenCallback cb) { + uint pos = 0; + Object? child = null; + do { + child = child_model.get_object (pos++); + } while (child != null && cb (child)); + } +} diff --git a/lib/meson.build b/lib/meson.build index a3215c723..8753937b5 100644 --- a/lib/meson.build +++ b/lib/meson.build @@ -36,6 +36,8 @@ libgranite_sources = files( 'Widgets/TimePicker.vala', 'Widgets/ToolBox.vala', 'Widgets/Toast.vala', + 'Widgets/TreeList/TreeList.vala', + 'Widgets/TreeList/TreeListItem.vala', 'Widgets/Utils.vala', 'Widgets/ValidatedEntry.vala' )