From afcc098e10782d8403ebbdcb912bfb4754fc25b1 Mon Sep 17 00:00:00 2001 From: Colin Kiama Date: Sat, 27 Jan 2024 11:11:37 +0000 Subject: [PATCH] [GTK4 Prep] Moved Granite.Widgets.SourceList source code into codebase (#1419) * Granite.Widgets.SourceList is now an internal Code widget * Use internal value for SourceList css class * Move SourceList widgets into new 'SourceList' directory * Use updated style classes in SourceList cell renderers --------- Co-authored-by: Jeremy Wootten --- src/FolderManager/FileView.vala | 16 +- src/FolderManager/FolderItem.vala | 26 +- src/FolderManager/Item.vala | 6 +- src/SymbolPane/C/CtagsSymbol.vala | 2 +- src/SymbolPane/C/CtagsSymbolOutline.vala | 8 +- src/SymbolPane/SymbolOutline.vala | 8 +- src/SymbolPane/Vala/ValaComparisonHelper.vala | 2 +- src/SymbolPane/Vala/ValaSymbolItem.vala | 4 +- src/SymbolPane/Vala/ValaSymbolOutline.vala | 20 +- src/Widgets/SourceList/CellRendererBadge.vala | 127 + .../SourceList/CellRendererExpander.vala | 107 + src/Widgets/SourceList/SourceList.vala | 2920 +++++++++++++++++ src/meson.build | 3 + 13 files changed, 3203 insertions(+), 46 deletions(-) create mode 100644 src/Widgets/SourceList/CellRendererBadge.vala create mode 100644 src/Widgets/SourceList/CellRendererExpander.vala create mode 100644 src/Widgets/SourceList/SourceList.vala diff --git a/src/FolderManager/FileView.vala b/src/FolderManager/FileView.vala index fb809245e2..c374d8675a 100644 --- a/src/FolderManager/FileView.vala +++ b/src/FolderManager/FileView.vala @@ -21,7 +21,7 @@ /** * SourceList that displays folders and their contents. */ -public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.PaneSwitcher { +public class Scratch.FolderManager.FileView : Code.Widgets.SourceList, Code.PaneSwitcher { private GLib.Settings settings; private Scratch.Services.GitManager git_manager; @@ -60,7 +60,7 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P }); } - private void on_item_selected (Granite.Widgets.SourceList.Item? item) { + private void on_item_selected (Code.Widgets.SourceList.Item? item) { // This is a workaround for SourceList silliness: you cannot remove an item // without it automatically selecting another one. if (ignore_next_select) { @@ -82,8 +82,8 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P public void open_folder (File folder) { if (is_open (folder)) { var existing = find_path (root, folder.path); - if (existing is Granite.Widgets.SourceList.ExpandableItem) { - ((Granite.Widgets.SourceList.ExpandableItem)existing).expanded = true; + if (existing is Code.Widgets.SourceList.ExpandableItem) { + ((Code.Widgets.SourceList.ExpandableItem)existing).expanded = true; } return; @@ -146,7 +146,7 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P } } - private unowned Granite.Widgets.SourceList.Item? find_path (Granite.Widgets.SourceList.ExpandableItem list, + private unowned Code.Widgets.SourceList.Item? find_path (Code.Widgets.SourceList.ExpandableItem list, string path, bool expand = false) { foreach (var item in list.children) { @@ -156,8 +156,8 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P return (!)item; } - if (item is Granite.Widgets.SourceList.ExpandableItem) { - var expander = item as Granite.Widgets.SourceList.ExpandableItem; + if (item is Code.Widgets.SourceList.ExpandableItem) { + var expander = item as Code.Widgets.SourceList.ExpandableItem; if (!path.has_prefix (code_item.path)) { continue; } @@ -194,7 +194,7 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P return null; } - public unowned Granite.Widgets.SourceList.Item? expand_to_path (string path) { + public unowned Code.Widgets.SourceList.Item? expand_to_path (string path) { return find_path (root, path, true); } diff --git a/src/FolderManager/FolderItem.vala b/src/FolderManager/FolderItem.vala index 2c82c4d73b..78947e5345 100644 --- a/src/FolderManager/FolderItem.vala +++ b/src/FolderManager/FolderItem.vala @@ -27,7 +27,7 @@ namespace Scratch.FolderManager { private GLib.FileMonitor monitor; private bool children_loaded = false; private bool has_dummy; - private Granite.Widgets.SourceList.Item dummy; /* Blank item for expanded empty folders */ + private Code.Widgets.SourceList.Item dummy; /* Blank item for expanded empty folders */ public FolderItem (File file, FileView view) requires (file.is_valid_directory) { Object (file: file, view: view); @@ -40,9 +40,9 @@ namespace Scratch.FolderManager { construct { selectable = false; - dummy = new Granite.Widgets.SourceList.Item (""); + dummy = new Code.Widgets.SourceList.Item (""); // Must add dummy on unexpanded folders else expander will not show - ((Granite.Widgets.SourceList.ExpandableItem)this).add (dummy); + ((Code.Widgets.SourceList.ExpandableItem)this).add (dummy); has_dummy = true; toggled.connect (on_toggled); @@ -63,7 +63,7 @@ namespace Scratch.FolderManager { file.children.size > 0) { foreach (var child in file.children) { - Granite.Widgets.SourceList.Item item = null; + Code.Widgets.SourceList.Item item = null; if (child.is_valid_directory ()) { item = new FolderItem (child, view); } else if (child.is_valid_textfile) { @@ -237,7 +237,7 @@ namespace Scratch.FolderManager { } } - private void remove_badge (Granite.Widgets.SourceList.Item item) { + private void remove_badge (Code.Widgets.SourceList.Item item) { if (item is FolderItem) { ((FolderItem) item).remove_all_badges (); } @@ -245,16 +245,16 @@ namespace Scratch.FolderManager { item.badge = ""; } - public new void add (Granite.Widgets.SourceList.Item item) { + public new void add (Code.Widgets.SourceList.Item item) { if (has_dummy && n_children == 1) { - ((Granite.Widgets.SourceList.ExpandableItem)this).remove (dummy); + ((Code.Widgets.SourceList.ExpandableItem)this).remove (dummy); has_dummy = false; } - ((Granite.Widgets.SourceList.ExpandableItem)this).add (item); + ((Code.Widgets.SourceList.ExpandableItem)this).add (item); } - public new void remove (Granite.Widgets.SourceList.Item item) { + public new void remove (Code.Widgets.SourceList.Item item) { if (item is FolderItem) { var folder = (FolderItem)item; foreach (var child in folder.children) { @@ -263,16 +263,16 @@ namespace Scratch.FolderManager { } view.ignore_next_select = true; - ((Granite.Widgets.SourceList.ExpandableItem)this).remove (item); + ((Code.Widgets.SourceList.ExpandableItem)this).remove (item); // Add back dummy if empty unless we are removing a rename item if (!(item is RenameItem || has_dummy || n_children > 0)) { - ((Granite.Widgets.SourceList.ExpandableItem)this).add (dummy); + ((Code.Widgets.SourceList.ExpandableItem)this).add (dummy); has_dummy = true; } } public new void clear () { - ((Granite.Widgets.SourceList.ExpandableItem)this).clear (); + ((Code.Widgets.SourceList.ExpandableItem)this).clear (); has_dummy = false; } @@ -436,7 +436,7 @@ namespace Scratch.FolderManager { } } - internal class RenameItem : Granite.Widgets.SourceList.Item { + internal class RenameItem : Code.Widgets.SourceList.Item { public bool is_folder { get; construct; } public RenameItem (string name, bool is_folder) { diff --git a/src/FolderManager/Item.vala b/src/FolderManager/Item.vala index 8be3a9eb82..0b25b86602 100644 --- a/src/FolderManager/Item.vala +++ b/src/FolderManager/Item.vala @@ -22,7 +22,7 @@ namespace Scratch.FolderManager { /** * Common abstract class for file and folder items. */ - public abstract class Item: Granite.Widgets.SourceList.ExpandableItem, Granite.Widgets.SourceListSortable { + public abstract class Item: Code.Widgets.SourceList.ExpandableItem, Code.Widgets.SourceListSortable { public File file { get; construct; } public FileView view { get; construct; } @@ -55,7 +55,7 @@ namespace Scratch.FolderManager { file.trash (); } - public int compare (Granite.Widgets.SourceList.Item a, Granite.Widgets.SourceList.Item b) { + public int compare (Code.Widgets.SourceList.Item a, Code.Widgets.SourceList.Item b) { if (a is RenameItem) { return -1; } else if (b is RenameItem) { @@ -102,7 +102,7 @@ namespace Scratch.FolderManager { } } - public ProjectFolderItem? get_root_folder (Granite.Widgets.SourceList.ExpandableItem? start = null) { + public ProjectFolderItem? get_root_folder (Code.Widgets.SourceList.ExpandableItem? start = null) { if (start == null) { start = this; } diff --git a/src/SymbolPane/C/CtagsSymbol.vala b/src/SymbolPane/C/CtagsSymbol.vala index fa87332937..1b9530f01f 100644 --- a/src/SymbolPane/C/CtagsSymbol.vala +++ b/src/SymbolPane/C/CtagsSymbol.vala @@ -16,7 +16,7 @@ * */ -public class Scratch.Services.CtagsSymbol : Granite.Widgets.SourceList.ExpandableItem { +public class Scratch.Services.CtagsSymbol : Code.Widgets.SourceList.ExpandableItem { public Scratch.Services.Document doc { get; construct set; } public int line { get; construct set; } diff --git a/src/SymbolPane/C/CtagsSymbolOutline.vala b/src/SymbolPane/C/CtagsSymbolOutline.vala index b69d1ec891..8efb71666f 100644 --- a/src/SymbolPane/C/CtagsSymbolOutline.vala +++ b/src/SymbolPane/C/CtagsSymbolOutline.vala @@ -53,7 +53,7 @@ public class Scratch.Services.CtagsSymbolOutline : Scratch.Services.SymbolOutlin private async void parse_output (GLib.Subprocess subprocess) { var parent_dependent = new Gee.LinkedList (); - var new_root = new Granite.Widgets.SourceList.ExpandableItem (_("Symbols")); + var new_root = new Code.Widgets.SourceList.ExpandableItem (_("Symbols")); var datainput = new GLib.DataInputStream (subprocess.get_stdout_pipe ()); try { @@ -206,7 +206,7 @@ public class Scratch.Services.CtagsSymbolOutline : Scratch.Services.SymbolOutlin }); } - private void destroy_root (Granite.Widgets.SourceList.ExpandableItem to_destroy) { + private void destroy_root (Code.Widgets.SourceList.ExpandableItem to_destroy) { var children = iterate_children (to_destroy); to_destroy.clear (); foreach (var item in children) { @@ -218,7 +218,7 @@ public class Scratch.Services.CtagsSymbolOutline : Scratch.Services.SymbolOutlin } } - private Gee.TreeSet iterate_children (Granite.Widgets.SourceList.ExpandableItem parent) { + private Gee.TreeSet iterate_children (Code.Widgets.SourceList.ExpandableItem parent) { var result = new Gee.TreeSet (); foreach (var child in parent.children) { result.add_all (iterate_children ((CtagsSymbol)child)); @@ -226,7 +226,7 @@ public class Scratch.Services.CtagsSymbolOutline : Scratch.Services.SymbolOutlin return result; } - CtagsSymbol? find_existing (string name, Granite.Widgets.SourceList.ExpandableItem parent) { + CtagsSymbol? find_existing (string name, Code.Widgets.SourceList.ExpandableItem parent) { CtagsSymbol match = null; foreach (var child in parent.children) { var child_symbol = child as CtagsSymbol; diff --git a/src/SymbolPane/SymbolOutline.vala b/src/SymbolPane/SymbolOutline.vala index 97b506894d..74b2a221e9 100644 --- a/src/SymbolPane/SymbolOutline.vala +++ b/src/SymbolPane/SymbolOutline.vala @@ -19,15 +19,15 @@ public abstract class Scratch.Services.SymbolOutline : Object { public Scratch.Services.Document doc { get; construct; } - protected Granite.Widgets.SourceList store; - protected Granite.Widgets.SourceList.ExpandableItem root; + protected Code.Widgets.SourceList store; + protected Code.Widgets.SourceList.ExpandableItem root; protected Gtk.CssProvider source_list_style_provider; public Gtk.Widget get_widget () { return store; } public abstract void parse_symbols (); construct { - store = new Granite.Widgets.SourceList (); - root = new Granite.Widgets.SourceList.ExpandableItem (_("Symbols")); + store = new Code.Widgets.SourceList (); + root = new Code.Widgets.SourceList.ExpandableItem (_("Symbols")); store.root.add (root); set_up_css (); diff --git a/src/SymbolPane/Vala/ValaComparisonHelper.vala b/src/SymbolPane/Vala/ValaComparisonHelper.vala index c5c2cd4e4c..62b99c78ab 100644 --- a/src/SymbolPane/Vala/ValaComparisonHelper.vala +++ b/src/SymbolPane/Vala/ValaComparisonHelper.vala @@ -17,7 +17,7 @@ */ namespace Scratch.Services.ValaComparison { - int sort_function (Granite.Widgets.SourceList.Item str1, Granite.Widgets.SourceList.Item str2) { + int sort_function (Code.Widgets.SourceList.Item str1, Code.Widgets.SourceList.Item str2) { if (!(str1 is ValaSymbolItem && str2 is ValaSymbolItem)) return str1.name.collate (str2.name); var a = (ValaSymbolItem) str1; diff --git a/src/SymbolPane/Vala/ValaSymbolItem.vala b/src/SymbolPane/Vala/ValaSymbolItem.vala index 673846a530..f3d407c762 100644 --- a/src/SymbolPane/Vala/ValaSymbolItem.vala +++ b/src/SymbolPane/Vala/ValaSymbolItem.vala @@ -16,7 +16,7 @@ * */ -public class Scratch.Services.ValaSymbolItem : Granite.Widgets.SourceList.ExpandableItem, Granite.Widgets.SourceListSortable { +public class Scratch.Services.ValaSymbolItem : Code.Widgets.SourceList.ExpandableItem, Code.Widgets.SourceListSortable { public Vala.Symbol symbol { get; set; } public ValaSymbolItem (Vala.Symbol symbol) { @@ -34,7 +34,7 @@ public class Scratch.Services.ValaSymbolItem : Granite.Widgets.SourceList.Expand debug ("Destroy Vala symbol"); } - public int compare (Granite.Widgets.SourceList.Item a, Granite.Widgets.SourceList.Item b) { + public int compare (Code.Widgets.SourceList.Item a, Code.Widgets.SourceList.Item b) { return ValaComparison.sort_function (a, b); } diff --git a/src/SymbolPane/Vala/ValaSymbolOutline.vala b/src/SymbolPane/Vala/ValaSymbolOutline.vala index 5adedfd7ab..c2f36f2ee9 100644 --- a/src/SymbolPane/Vala/ValaSymbolOutline.vala +++ b/src/SymbolPane/Vala/ValaSymbolOutline.vala @@ -77,7 +77,7 @@ public class Scratch.Services.ValaSymbolOutline : Scratch.Services.SymbolOutline var root_children = store.root.children; // Keep reference to children for later destruction store.root.clear (); // This does not destroy children but disconnects signals - avoids terminal warnings foreach (var child in root_children) { // Destroy items after clearing list to avoid memory leak - destroy_all_children ((Granite.Widgets.SourceList.ExpandableItem)child); + destroy_all_children ((Code.Widgets.SourceList.ExpandableItem)child); } store.root.add (new_root); @@ -93,27 +93,27 @@ public class Scratch.Services.ValaSymbolOutline : Scratch.Services.SymbolOutline }); } - private void destroy_all_children (Granite.Widgets.SourceList.ExpandableItem parent) { + private void destroy_all_children (Code.Widgets.SourceList.ExpandableItem parent) { foreach (var child in parent.children) { remove (child, parent); } } - private new void remove (Granite.Widgets.SourceList.Item item, Granite.Widgets.SourceList.ExpandableItem parent) { - if (item is Granite.Widgets.SourceList.ExpandableItem) { - destroy_all_children ((Granite.Widgets.SourceList.ExpandableItem)item); + private new void remove (Code.Widgets.SourceList.Item item, Code.Widgets.SourceList.ExpandableItem parent) { + if (item is Code.Widgets.SourceList.ExpandableItem) { + destroy_all_children ((Code.Widgets.SourceList.ExpandableItem)item); } parent.remove (item); } - private Granite.Widgets.SourceList.ExpandableItem construct_tree (GLib.Cancellable cancellable) { + private Code.Widgets.SourceList.ExpandableItem construct_tree (GLib.Cancellable cancellable) { var fields = resolver.get_properties_fields (); var symbols = resolver.get_symbols (); // Remove fake fields created by the vala parser. symbols.remove_all (fields); - var new_root = new Granite.Widgets.SourceList.ExpandableItem (_("Symbols")); + var new_root = new Code.Widgets.SourceList.ExpandableItem (_("Symbols")); foreach (var symbol in symbols) { if (cancellable.is_cancelled ()) break; @@ -130,8 +130,8 @@ public class Scratch.Services.ValaSymbolOutline : Scratch.Services.SymbolOutline return new_root; } - private ValaSymbolItem construct_child (Vala.Symbol symbol, Granite.Widgets.SourceList.ExpandableItem given_parent, GLib.Cancellable cancellable) { - Granite.Widgets.SourceList.ExpandableItem parent; + private ValaSymbolItem construct_child (Vala.Symbol symbol, Code.Widgets.SourceList.ExpandableItem given_parent, GLib.Cancellable cancellable) { + Code.Widgets.SourceList.ExpandableItem parent; if (symbol.scope.parent_scope.owner.name == null) parent = given_parent; else @@ -194,7 +194,7 @@ public class Scratch.Services.ValaSymbolOutline : Scratch.Services.SymbolOutline return tree_child; } - ValaSymbolItem? find_existing (Vala.Symbol symbol, Granite.Widgets.SourceList.ExpandableItem parent, GLib.Cancellable cancellable) { + ValaSymbolItem? find_existing (Vala.Symbol symbol, Code.Widgets.SourceList.ExpandableItem parent, GLib.Cancellable cancellable) { ValaSymbolItem match = null; foreach (var _child in parent.children) { if (cancellable.is_cancelled ()) diff --git a/src/Widgets/SourceList/CellRendererBadge.vala b/src/Widgets/SourceList/CellRendererBadge.vala new file mode 100644 index 0000000000..b3f4488e8d --- /dev/null +++ b/src/Widgets/SourceList/CellRendererBadge.vala @@ -0,0 +1,127 @@ +/* + * Copyright 2019 elementary, Inc. (https://elementary.io) + * Copyright 2012–2013 Victor Eduardo + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * A badge renderer. + * + * Informs the user quickly on the content of the corresponding view. For example + * it might be used to show how much songs are in a playlist or how much updates + * are available. + * + * {{../doc/images/cellrendererbadge.png}} + * + * @since 0.2 + */ +public class Code.Widgets.CellRendererBadge : Gtk.CellRenderer { + + public string text { get; set; default = ""; } + + private Pango.Rectangle text_logical_rect; + private Pango.Layout text_layout; + private Gtk.Border margin; + private Gtk.Border padding; + private Gtk.Border border; + + public CellRendererBadge () { + } + + public override Gtk.SizeRequestMode get_request_mode () { + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void get_preferred_width ( + Gtk.Widget widget, + out int minimum_size, + out int natural_size + ) { + update_layout_properties (widget); + + int width = text_logical_rect.width; + width += margin.left + margin.right; + width += padding.left + padding.right; + width += border.left + border.right; + + minimum_size = natural_size = width + 2 * (int) xpad; + } + + public override void get_preferred_height_for_width ( + Gtk.Widget widget, int width, + out int minimum_height, + out int natural_height + ) { + update_layout_properties (widget); + + int height = text_logical_rect.height; + height += margin.top + margin.bottom; + height += padding.top + padding.bottom; + height += border.top + border.bottom; + + minimum_height = natural_height = height + 2 * (int) ypad; + } + + private void update_layout_properties (Gtk.Widget widget) { + var ctx = widget.get_style_context (); + ctx.save (); + + // Add class before creating the pango layout and fetching paddings. + // This is needed in order to fetch the proper style information. + ctx.add_class (Granite.STYLE_CLASS_BADGE); + + var state = ctx.get_state (); + + margin = ctx.get_margin (state); + padding = ctx.get_padding (state); + border = ctx.get_border (state); + + text_layout = widget.create_pango_layout (text); + + ctx.restore (); + + Pango.Rectangle ink_rect; + text_layout.get_pixel_extents (out ink_rect, out text_logical_rect); + } + + public override void render ( + Cairo.Context context, + Gtk.Widget widget, + Gdk.Rectangle bg_area, + Gdk.Rectangle cell_area, + Gtk.CellRendererState flags + ) { + update_layout_properties (widget); + + Gdk.Rectangle aligned_area = get_aligned_area (widget, flags, cell_area); + + int x = aligned_area.x; + int y = aligned_area.y; + int width = aligned_area.width; + int height = aligned_area.height; + + // Apply margin + x += margin.right; + y += margin.top; + width -= margin.left + margin.right; + height -= margin.top + margin.bottom; + + var ctx = widget.get_style_context (); + ctx.add_class (Granite.STYLE_CLASS_BADGE); + + ctx.render_background (context, x, y, width, height); + ctx.render_frame (context, x, y, width, height); + + // Apply border width and padding offsets + x += border.right + padding.right; + y += border.top + padding.top; + width -= border.left + border.right + padding.left + padding.right; + height -= border.top + border.bottom + padding.top + padding.bottom; + + // Center text + x += text_logical_rect.x + (width - text_logical_rect.width) / 2; + y += text_logical_rect.y + (height - text_logical_rect.height) / 2; + + ctx.render_layout (context, x, y, text_layout); + } +} diff --git a/src/Widgets/SourceList/CellRendererExpander.vala b/src/Widgets/SourceList/CellRendererExpander.vala new file mode 100644 index 0000000000..faa78e19e4 --- /dev/null +++ b/src/Widgets/SourceList/CellRendererExpander.vala @@ -0,0 +1,107 @@ +/* + * Copyright 2012–2019 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * An expander renderer. + * + * For it to draw an expander, the the {@link Gtk.CellRenderer.is_expander} property must + * be set to true; otherwise nothing is drawn. The state of the expander (i.e. expanded or + * collapsed) is controlled by the {@link Gtk.CellRenderer.is_expanded} property. + * + * @since 0.2 + */ +public class Code.Widgets.CellRendererExpander : Gtk.CellRenderer { + public bool is_category_expander { get; set; default = false; } + + public CellRendererExpander () { + } + + public override Gtk.SizeRequestMode get_request_mode () { + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void get_preferred_width ( + Gtk.Widget widget, + out int minimum_size, + out int natural_size + ) { + apply_style_changes (widget); + minimum_size = natural_size = get_arrow_size (widget) + 2 * (int) xpad; + revert_style_changes (widget); + } + + public override void get_preferred_height_for_width ( + Gtk.Widget widget, int width, + out int minimum_height, + out int natural_height + ) { + apply_style_changes (widget); + minimum_height = natural_height = get_arrow_size (widget) + 2 * (int) ypad; + revert_style_changes (widget); + } + + /** + * Gets the size of the expander arrow. + * + * The default implementation tries to retrieve the "expander-size" style property from + * //widget//, as it is primarily meant to be used along with a {@link Gtk.TreeView}. + * For those with special needs, it is recommended to override this method. + * + * @param widget Widget used to query the "expander-size" style property (should be a Gtk.TreeView.) + * @return Size of the expander arrow. + * @since 0.2 + */ + public virtual int get_arrow_size (Gtk.Widget widget) { + int arrow_size; + widget.style_get ("expander-size", out arrow_size); + return arrow_size; + } + + public override void render ( + Cairo.Context context, + Gtk.Widget widget, + Gdk.Rectangle bg_area, + Gdk.Rectangle cell_area, + Gtk.CellRendererState flags + ) { + if (!is_expander) { + return; + } + + unowned Gtk.StyleContext ctx = apply_style_changes (widget); + + Gdk.Rectangle aligned_area = get_aligned_area (widget, flags, cell_area); + + int arrow_size = int.min (get_arrow_size (widget), aligned_area.width); + + int offset = arrow_size / 2; + int x = aligned_area.x + aligned_area.width / 2 - offset; + int y = aligned_area.y + aligned_area.height / 2 - offset; + + var state = ctx.get_state (); + const Gtk.StateFlags EXPANDED_FLAG = Gtk.StateFlags.CHECKED; + ctx.set_state (is_expanded ? state | EXPANDED_FLAG : state & ~EXPANDED_FLAG); + + ctx.render_expander (context, x, y, arrow_size, arrow_size); + + revert_style_changes (widget); + } + + private unowned Gtk.StyleContext apply_style_changes (Gtk.Widget widget) { + unowned Gtk.StyleContext ctx = widget.get_style_context (); + ctx.save (); + + if (is_category_expander) + ctx.add_class (Granite.STYLE_CLASS_CATEGORY_EXPANDER); + else + ctx.add_class (Gtk.STYLE_CLASS_EXPANDER); + + return ctx; + } + + private void revert_style_changes (Gtk.Widget widget) { + widget.get_style_context ().restore (); + } +} diff --git a/src/Widgets/SourceList/SourceList.vala b/src/Widgets/SourceList/SourceList.vala new file mode 100644 index 0000000000..ee3e5af50a --- /dev/null +++ b/src/Widgets/SourceList/SourceList.vala @@ -0,0 +1,2920 @@ +/* + * Copyright 2019 elementary, Inc. (https://elementary.io) + * Copyright 2012-2014 Victor Martinez + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +namespace Code.Widgets { + +/** + * An interface for sorting items. + * + * @since 0.3 + */ +public interface SourceListSortable : SourceList.ExpandableItem { + /** + * Emitted after a user has re-ordered an item via DnD. + * + * @param moved The item that was moved to a different position by the user. + * @since 0.3 + */ + public signal void user_moved_item (SourceList.Item moved); + + /** + * Whether this item will allow users to re-arrange its children via DnD. + * + * This feature can co-exist with a sort algorithm (implemented + * by {@link Code.Widgets.SourceListSortable.compare}), but + * the actual order of the items in the list will always + * honor that method. The sort function has to be compatible with + * the kind of DnD reordering the item wants to allow, since the user can + * only reorder those items for which //compare// returns 0. + * + * @return Whether the item's children can be re-arranged by users. + * @since 0.3 + */ + public abstract bool allow_dnd_sorting (); + + /** + * Should return a negative integer, zero, or a positive integer if ''a'' + * sorts //before// ''b'', ''a'' sorts //with// ''b'', or ''a'' sorts + * //after// ''b'' respectively. If two items compare as equal, their + * order in the sorted source list is undefined. + * + * In order to ensure that the source list behaves as expected, this + * method must define a partial order on the source list tree; i.e. it + * must be reflexive, antisymmetric and transitive. Not complying with + * those requirements could make the program fall into an infinite loop + * and freeze the user interface. + * + * Should return //0// to allow any pair of items to be sortable via DnD. + * + * @param a First item. + * @param b Second item. + * @return A //negative// integer if //a// sorts before //b//, + * //zero// if //a// equals //b//, or a //positive// + * integer if //a// sorts after //b//. + * @since 0.3 + */ + public abstract int compare (SourceList.Item a, SourceList.Item b); +} + +/** + * An interface for dragging items out of the source list widget. + * + * @since 0.3 + */ +public interface SourceListDragSource : SourceList.Item { + /** + * Determines whether this item can be dragged outside the source list widget. + * + * Even if this method returns //false//, the item could still be dragged around + * within the source list if its parent allows DnD reordering. This only happens + * when the parent implements {@link Code.Widgets.SourceListSortable}. + * + * @return //true// if the item can be dragged; //false// otherwise. + * @since 0.3 + * @see Code.Widgets.SourceListSortable + */ + public abstract bool draggable (); + + /** + * This method is called when the drop site requests the data which is dragged. + * + * It is the responsibility of this method to fill //selection_data// with the + * data in the format which is indicated by {@link Gtk.SelectionData.get_target}. + * + * @param selection_data {@link Gtk.SelectionData} containing source data. + * @since 0.3 + * @see Gtk.SelectionData.set + * @see Gtk.SelectionData.set_uris + * @see Gtk.SelectionData.set_text + */ + public abstract void prepare_selection_data (Gtk.SelectionData selection_data); +} + +/** + * An interface for receiving data from other widgets via drag-and-drop. + * + * @since 0.3 + */ +public interface SourceListDragDest : SourceList.Item { + /** + * Determines whether //data// can be dropped into this item. + * + * @param context The drag context. + * @param data {@link Gtk.SelectionData} containing source data. + * @return //true// if the drop is possible; //false// otherwise. + * @since 0.3 + */ + public abstract bool data_drop_possible (Gdk.DragContext context, Gtk.SelectionData data); + + /** + * If a data drop is deemed possible, then this method is called + * when the data is actually dropped into this item. Any actions + * consequence of the data received should be handled here. + * + * @param context The drag context. + * @param data {@link Gtk.SelectionData} containing source data. + * @return The action taken, or //0// to indicate that the dropped data was not accepted. + * @since 0.3 + */ + public abstract Gdk.DragAction data_received (Gdk.DragContext context, Gtk.SelectionData data); +} + +/** + * A widget that can display a list of items organized in categories. + * + * The source list widget consists of a collection of items, some of which are also expandable (and + * thus can contain more items). All the items displayed in the source list are children of the widget's + * root item. The API is meant to be used as follows: + * + * 1. Create the items you want to display in the source list, setting the appropriate values for their + * properties. The desired hierarchy is achieved by creating expandable items and adding items to them. + * These will be displayed as descendants in the widget's tree structure. The expandable items that are + * not nested inside any other item are considered to be at root level, and should be added to + * the widget's root item.<
> + * + * Expandable items located at the root level are treated as categories, and only support text. + * + * ''Example''<
> + * The final tree will have the following structure: + * {{{ + * Libraries + * Music + * Stores + * My Store + * Music + * Podcasts + * Devices + * Player 1 + * Player 2 + * }}} + * + * {{{ + * var library_category = new Code.Widgets.SourceList.ExpandableItem ("Libraries"); + * var store_category = new Code.Widgets.SourceList.ExpandableItem ("Stores"); + * var device_category = new Code.Widgets.SourceList.ExpandableItem ("Devices"); + * + * var music_item = new Code.Widgets.SourceList.Item ("Music"); + * + * // "Libraries" will be the parent category of "Music" + * library_category.add (music_item); + * + * // We plan to add sub-items to the store, so let's use an expandable item + * var my_store_item = new Code.Widgets.SourceList.ExpandableItem ("My Store"); + * store_category.add (my_store_item); + * + * var my_store_podcast_item = new Code.Widgets.SourceList.Item ("Podcasts"); + * var my_store_music_item = new Code.Widgets.SourceList.Item ("Music"); + * + * my_store_item.add (my_store_music_item); + * my_store_item.add (my_store_podcast_item); + * + * var player1_item = new Code.Widgets.SourceList.Item ("Player 1"); + * var player2_item = new Code.Widgets.SourceList.Item ("Player 2"); + * + * device_category.add (player1_item); + * device_category.add (player2_item); + * }}} + * + * 2. Create a source list widget.<
> + * {{{ + * var source_list = new Code.Widgets.SourceList (); + * }}} + * + * 3. Add root-level items to the {@link Code.Widgets.SourceList.root} item. + * This item only serves as a container, and all its properties are ignored by the widget. + * + * {{{ + * // This will add the main categories (including their children) to the source list. After + * // having being added to be widget, any other item added to any of these items + * // (or any other child item in a deeper level) will be automatically added too. + * // There's no need to deal with the source list widget directly. + * + * var root = source_list.root; + * + * root.add (library_category); + * root.add (store_category); + * root.add (device_category); + * }}} + * + * The steps mentioned above are enough for initializing the source list. Future changes to the items' + * properties are ''automatically'' reflected by the widget. + * + * Final steps would involve connecting handlers to the source list events, being + * {@link Code.Widgets.SourceList.item_selected} the most important, as it indicates that + * the selection was modified. + * + * Pack the source list into the GUI using the {@link Gtk.Paned} widget. + * This is usually done as follows: + * {{{ + * var pane = new Gtk.Paned (Gtk.Orientation.HORIZONTAL); + * pane.pack1 (source_list, false, false); + * pane.pack2 (content_area, true, false); + * }}} + * + * @since 0.2 + * @see Gtk.Paned + */ +public class SourceList : Gtk.ScrolledWindow { + + /** + * = WORKING INTERNALS = + * + * In order to offer a transparent Item-based API, and avoid the need of providing methods + * to deal with items directly on the SourceList widget, it was decided to follow a monitor-like + * implementation, where the source list permanently monitors its root item and any other + * child item added to it. The task of monitoring the properties of the items has been + * divided among different objects, as shown below: + * + * Monitored by: Object::method that receives the signals indicating the property change. + * Applied by: Object::method that actually updates the tree to reflect the property changes + * (directly or indirectly, as in the case of the tree data model). + * + * --------------------------------------------------------------------------------------------- + * PROPERTY | MONITORED BY | APPLIED BY + * --------------------------------------------------------------------------------------------- + * + Item | | + * - parent | Not monitored | N/A + * - name | DataModel::on_item_prop_changed | Tree::name_cell_data_func + * - editable | DataModel::on_item_prop_changed | Queried when needed (See Tree::start_editing_item) + * - visible | DataModel::on_item_prop_changed | DataModel::filter_visible_func + * - icon | DataModel::on_item_prop_changed | Tree::icon_cell_data_func + * - activatable | Same as @icon | Same as @icon + * + ExpandableItem | | + * - collapsible | DataModel::on_item_prop_changed | Tree::update_expansion + * | | Tree::expander_cell_data_func + * - expanded | Same as @collapsible | Same as @collapsible + * --------------------------------------------------------------------------------------------- + * * Only automatic properties are monitored. ExpandableItem's additions/removals are handled by + * DataModel::add_item() and DataModel::remove_item() + * + * Other features: + * - Sorting: this happens on the tree-model level (DataModel). + */ + + + + /** + * A source list entry. + * + * Any change made to any of its properties will be ''automatically'' reflected + * by the {@link Code.Widgets.SourceList} widget. + * + * @since 0.2 + */ + public class Item : Object { + + /** + * Emitted when the user has finished editing the item's name. + * + * By default, if the name doesn't consist of white space, it is automatically assigned + * to the {@link Code.Widgets.SourceList.Item.name} property. The default behavior can + * be changed by overriding this signal. + * @param new_name The item's new name (result of editing.) + * @since 0.2 + */ + public virtual signal void edited (string new_name) { + if (editable && new_name.strip () != "") + this.name = new_name; + } + + /** + * The {@link Code.Widgets.SourceList.Item.activatable} icon was activated. + * + * @see Code.Widgets.SourceList.Item.activatable + * @since 0.2 + */ + public virtual signal void action_activated () { } + + /** + * Emitted when the item is double-clicked or when it is selected and one of the keys: + * Space, Shift+Space, Return or Enter is pressed. This signal is //also// for + * editable items. + * + * @since 0.2 + */ + public virtual signal void activated () { } + + /** + * Parent {@link Code.Widgets.SourceList.ExpandableItem} of the item. + * ''Must not'' be modified. + * + * @since 0.2 + */ + public ExpandableItem parent { get; internal set; } + + /** + * The item's name. Primary and most important information. + * + * @since 0.2 + */ + public string name { get; set; default = ""; } + + /** + * The item's tooltip. If set to null (default), the tooltip for the item will be the + * contents of the {@link Code.Widgets.SourceList.Item.name} property. + * + * @since 5.3 + */ + public string? tooltip { get; set; default = null; } + + /** + * Markup to be used instead of {@link Code.Widgets.SourceList.ExpandableItem.name} + * This would mean that &, <, etc have to be escaped in the text, but basic formatting + * can be done on the item with HTML style tags. + * + * Note: Only the {@link Code.Widgets.SourceList.ExpandableItem.name} property + * is modified for editable items. So this property will be need to updated and + * reformatted with editable items. + * + * @since 5.0 + */ + public string? markup { get; set; default = null; } + + /** + * A badge shown next to the item's name. + * + * It can be used for displaying the number of unread messages in the "Inbox" item, + * for instance. + * + * @since 0.2 + */ + public string badge { get; set; default = ""; } + + /** + * Whether the item's name can be edited from within the source list. + * + * When this property is set to //true//, users can edit the item by pressing + * the F2 key, or by double-clicking its name. + * + * ''This property only works for selectable items''. + * + * @see Code.Widgets.SourceList.Item.selectable + * @see Code.Widgets.SourceList.start_editing_item + * @since 0.2 + */ + public bool editable { get; set; default = false; } + + /** + * Whether the item should appear in the source list's tree or not. + * + * @since 0.2 + */ + public bool visible { get; set; default = true; } + + /** + * Whether the item can be selected or not. + * + * Setting this property to true doesn't guarantee that the item will actually be + * selectable, since there are other external factors to take into account, like the + * item's {@link Code.Widgets.SourceList.Item.visible} property; whether the item is + * a category; the parent item is collapsed, etc. + * + * @see Code.Widgets.SourceList.Item.visible + * @since 0.2 + */ + public bool selectable { get; set; default = true; } + + /** + * Primary icon. + * + * This property should be used to give the user an idea of what the item represents + * (i.e. content type.) + * + * @since 0.2 + */ + public Icon icon { get; set; } + + /** + * An activatable icon that works like a button. + * + * It can be used for e.g. showing an //"eject"// icon on a device's item. + * + * @see Code.Widgets.SourceList.Item.action_activated + * @since 0.2 + */ + public Icon activatable { get; set; } + + /** + * The tooltip for the activatable icon. + * + * @since 5.0 + */ + public string activatable_tooltip { get; set; default = ""; } + + /** + * Creates a new {@link Code.Widgets.SourceList.Item}. + * + * @param name Name of the item. + * @return (transfer full) A new {@link Code.Widgets.SourceList.Item}. + * @since 0.2 + */ + public Item (string name = "") { + this.name = name; + } + + /** + * Invoked when the item is secondary-clicked or when the usual menu keys are pressed. + * + * Note that since Granite 5.0, right clicking on an item no longer selects/activates it, so + * any context menu items should be actioned on the item instance rather than the selected item + * in the SourceList + * + * @return A {@link Gtk.Menu} or //null// if nothing should be displayed. + * @since 0.2 + */ + public virtual Gtk.Menu? get_context_menu () { + return null; + } + } + + + + /** + * An item that can contain more items. + * + * It supports all the properties inherited from {@link Code.Widgets.SourceList.Item}, + * and behaves like a normal item, except when it is located at the root level; in that case, + * the following properties are ignored by the widget: + * + * * {@link Code.Widgets.SourceList.Item.selectable} + * * {@link Code.Widgets.SourceList.Item.editable} + * * {@link Code.Widgets.SourceList.Item.icon} + * * {@link Code.Widgets.SourceList.Item.activatable} + * * {@link Code.Widgets.SourceList.Item.badge} + * + * Root-level expandable items (i.e. Main Categories) are ''not'' displayed when they contain + * zero visible children. + * + * @since 0.2 + */ + public class ExpandableItem : Item { + + /** + * Emitted when an item is added. + * + * @param item Item added. + * @see Code.Widgets.SourceList.ExpandableItem.add + * @since 0.2 + */ + public signal void child_added (Item item); + + /** + * Emitted when an item is removed. + * + * @param item Item removed. + * @see Code.Widgets.SourceList.ExpandableItem.remove + * @since 0.2 + */ + public signal void child_removed (Item item); + + /** + * Emitted when the item is expanded or collapsed. + * + * @since 0.2 + */ + public virtual signal void toggled () { } + + /** + * Whether the item is collapsible or not. + * + * When set to //false//, the item is //always// expanded and the expander is + * not shown. Please note that this will also affect the value returned by the + * {@link Code.Widgets.SourceList.ExpandableItem.expanded} property. + * + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @since 0.2 + */ + public bool collapsible { get; set; default = true; } + + /** + * Whether the item is expanded or not. + * + * The source list widget will obey the value of this property when possible. + * + * This property has no effect when {@link Code.Widgets.SourceList.ExpandableItem.collapsible} + * is set to //false//. Also keep in mind that, __when set to //true//__, this property + * doesn't always represent the actual expansion state of an item. For example, it might + * be the case that an expandable item is collapsed because it has zero visible children, + * but its //expanded// property value is still //true//; in such case, once one of the + * item's children becomes visible, the item will be expanded again. Same applies to items + * hidden behind a collapsed parent item. + * + * If obtaining the ''actual'' expansion state of an item is important, + * use {@link Code.Widgets.SourceList.is_item_expanded} instead. + * + * @see Code.Widgets.SourceList.ExpandableItem.collapsible + * @see Code.Widgets.SourceList.is_item_expanded + * @since 0.2 + */ + private bool _expanded = false; + public bool expanded { + get { return _expanded || !collapsible; } // if not collapsible, always return true + set { + if (value != _expanded) { + _expanded = value; + toggled (); + } + } + } + + /** + * Number of children contained by the item. + * + * @since 0.2 + */ + public uint n_children { + get { return children_list.size; } + } + + /** + * The item's children. + * + * This returns a newly-created list containing the children. + * It's safe to iterate it while removing items with + * {@link Code.Widgets.SourceList.ExpandableItem.remove} + * + * @since 0.2 + */ + public Gee.Collection children { + owned get { + // Create a copy of the children so that it's safe to iterate it + // (e.g. by using foreach) while removing items. + var children_list_copy = new Gee.ArrayList (); + children_list_copy.add_all (children_list); + return children_list_copy; + } + } + + private Gee.Collection children_list = new Gee.ArrayList (); + + /** + * Creates a new {@link Code.Widgets.SourceList.ExpandableItem} + * + * @param name Title of the item. + * @return (transfer full) A new {@link Code.Widgets.SourceList.ExpandableItem}. + * @since 0.2 + */ + public ExpandableItem (string name = "") { + base (name); + } + + construct { + editable = false; + } + + /** + * Checks whether the item contains the specified child. + * + * This method only considers the item's immediate children. + * + * @param item Item to search. + * @return Whether the item was found or not. + * @since 0.2 + */ + public bool contains (Item item) { + return item in children_list; + } + + /** + * Adds an item. + * + * {@link Code.Widgets.SourceList.ExpandableItem.child_added} is fired after the item is added. + * + * While adding a child item, //the item it's being added to will set itself as the parent//. + * Please note that items are required to have their //parent// property set to //null// before + * being added, so make sure the item is removed from its previous parent before attempting + * to add it to another item. For instance: + * {{{ + * if (item.parent != null) + * item.parent.remove (item); // this will set item's parent to null + * new_parent.add (item); + * }}} + * + * @param item The item to add. Its parent __must__ be //null//. + * @see Code.Widgets.SourceList.ExpandableItem.child_added + * @see Code.Widgets.SourceList.ExpandableItem.remove + * @since 0.2 + */ + public void add (Item item) requires (item.parent == null) { + item.parent = this; + children_list.add (item); + child_added (item); + } + + /** + * Removes an item. + * + * The {@link Code.Widgets.SourceList.ExpandableItem.child_removed} signal is fired + * //after removing the item//. Finally (i.e. after all the handlers have been invoked), + * the item's {@link Code.Widgets.SourceList.Item.parent} property is set to //null//. + * This has the advantage of letting signal handlers know the parent from which //item// + * is being removed. + * + * @param item The item to remove. This will fail if item has a different parent. + * @see Code.Widgets.SourceList.ExpandableItem.child_removed + * @see Code.Widgets.SourceList.ExpandableItem.clear + * @since 0.2 + */ + public void remove (Item item) requires (item.parent == this) { + children_list.remove (item); + child_removed (item); + item.parent = null; + } + + /** + * Removes all the items contained by the item. It works similarly to + * {@link Code.Widgets.SourceList.ExpandableItem.remove}. + * + * @see Code.Widgets.SourceList.ExpandableItem.remove + * @see Code.Widgets.SourceList.ExpandableItem.child_removed + * @since 0.2 + */ + public void clear () { + foreach (var item in children) + remove (item); + } + + /** + * Expands the item and/or its children. + * + * @param inclusive Whether to also expand this item (true), or only its children (false). + * @param recursive Whether to recursively expand all the children (true), or only + * immediate children (false). + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @since 0.2 + */ + public void expand_all (bool inclusive = true, bool recursive = true) { + set_expansion (this, inclusive, recursive, true); + } + + /** + * 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). + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @since 0.2 + */ + public void collapse_all (bool inclusive = true, bool recursive = true) { + set_expansion (this, inclusive, recursive, false); + } + + private static void set_expansion (ExpandableItem item, bool inclusive, bool recursive, bool expanded) { + if (inclusive) + item.expanded = expanded; + + foreach (var child_item in item.children) { + var child_expandable_item = child_item as ExpandableItem; + if (child_expandable_item != null) { + if (recursive) + set_expansion (child_expandable_item, true, true, expanded); + else + child_expandable_item.expanded = expanded; + } + } + } + + /** + * Recursively expands the item along with its parent(s). + * + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @since 0.2 + */ + public void expand_with_parents () { + // Update parent items first due to GtkTreeView's working internals: + // Expanding children before their parents would not always work, because + // they could be obscured behind a collapsed row by the time the treeview + // tries to expand them, obviously failing. + if (parent != null) + parent.expand_with_parents (); + expanded = true; + } + + /** + * Recursively collapses the item along with its parent(s). + * + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @since 0.2 + */ + public void collapse_with_parents () { + if (parent != null) + parent.collapse_with_parents (); + expanded = false; + } + } + + + + /** + * The model backing the SourceList tree. + * + * It monitors item property changes, and handles children additions and removals. It also controls + * the visibility of the items based on their "visible" property, and on their number of children, + * if they happen to be categories. Its main purpose is to provide an easy and practical interface + * for sorting, adding, removing and updating items, eliminating the need of repeatedly dealing with + * the Gtk.TreeModel API directly. + */ + private class DataModel : Gtk.TreeModelFilter, Gtk.TreeDragSource, Gtk.TreeDragDest { + + /** + * An object that references a particular row in a model. This class is a wrapper built around + * Gtk.TreeRowReference, and exists with the purpose of ensuring we never use invalid tree paths + * or iters in the model, since most of these errors provoke failures due to GTK+ assertions + * or, even worse, unexpected behavior. + */ + private class NodeWrapper { + + /** + * The actual reference to the node. If is is null, it is treated as invalid. + */ + private Gtk.TreeRowReference? row_reference; + + /** + * A newly-created Gtk.TreeIter pointing to the node if it exists; null otherwise. + */ + public Gtk.TreeIter? iter { + owned get { + Gtk.TreeIter? rv = null; + + if (valid) { + var _path = this.path; + if (_path != null) { + Gtk.TreeIter _iter; + if (row_reference.get_model ().get_iter (out _iter, _path)) + rv = _iter; + } + } + + return rv; + } + } + + /** + * A newly-created Gtk.TreePath pointing to the node if it exists; null otherwise. + */ + public Gtk.TreePath? path { + owned get { return valid ? row_reference.get_path () : null; } + } + + /** + * Whether the node is valid or not. When it is not valid, no valid references are + * returned by the object to avoid errors (null is returned instead). + */ + public bool valid { + get { return row_reference != null && row_reference.valid (); } + } + + public NodeWrapper (Gtk.TreeModel model, Gtk.TreeIter iter) { + row_reference = new Gtk.TreeRowReference (model, model.get_path (iter)); + } + } + + /** + * Helper object used to monitor item property changes. + */ + private class ItemMonitor { + public signal void changed (Item self, string prop_name); + private Item item; + + public ItemMonitor (Item item) { + this.item = item; + item.notify.connect_after (on_notify); + } + + ~ItemMonitor () { + item.notify.disconnect (on_notify); + } + + private void on_notify (ParamSpec prop) { + changed (item, prop.name); + } + } + + private enum Column { + ITEM, + N_COLUMNS; + + public Type type () { + switch (this) { + case ITEM: + return typeof (Item); + + default: + assert_not_reached (); // a Type must be returned for every valid column + } + } + } + + public signal void item_updated (Item item); + + /** + * Used by push_parent_update() as key to associate the respective data to the objects. + */ + private const string ITEM_PARENT_NEEDS_UPDATE = "item-parent-needs-update"; + + private ExpandableItem _root; + + /** + * Root item. + * + * This item is not actually part of the model. It's only used as a proxy + * for adding and removing items. + */ + public ExpandableItem root { + get { return _root; } + set { + if (_root != null) { + remove_children_monitor (_root); + foreach (var item in _root.children) + remove_item (item); + } + + _root = value; + + add_children_monitor (_root); + foreach (var item in _root.children) + add_item (item); + } + } + + // This hash map stores items and their respective child node references. For that reason, the + // references it contains should only be used on the child_tree model, or converted to filter + // iters/paths using convert_child_*_to_*() before using them with the filter (i.e. this) model. + private Gee.HashMap items = new Gee.HashMap (); + + private Gee.HashMap monitors = new Gee.HashMap (); + + private Gtk.TreeStore child_tree; + private unowned SourceList.VisibleFunc? filter_func; + + public DataModel () { + + } + + construct { + child_tree = new Gtk.TreeStore (Column.N_COLUMNS, Column.ITEM.type ()); + child_model = child_tree; + virtual_root = null; + + child_tree.set_default_sort_func (child_model_sort_func); + resort (); + + set_visible_func (filter_visible_func); + } + + public bool has_item (Item item) { + return items.has_key (item); + } + + public void update_item (Item item) requires (has_item (item)) { + assert (root != null); + + // Emitting row_changed() for this item's row in the child model causes the filter + // (i.e. this model) to re-evaluate whether a row is visible or not, calling + // filter_visible_func() for that row again, and that's exactly what we want. + var node_reference = items.get (item); + if (node_reference != null) { + var path = node_reference.path; + var iter = node_reference.iter; + if (path != null && iter != null) { + child_tree.row_changed (path, iter); + item_updated (item); + } + } + } + + private void add_item (Item item) requires (!has_item (item)) { + assert (root != null); + + // Find the parent iter + Gtk.TreeIter? parent_child_iter = null, child_iter; + var parent = item.parent; + + if (parent != null && parent != root) { + // Add parent if it hasn't been added yet + if (!has_item (parent)) + add_item (parent); + + // Try to find the parent's iter + parent_child_iter = get_item_child_iter (parent); + + // Parent must have been added prior to adding this item + assert (parent_child_iter != null); + } + + child_tree.append (out child_iter, parent_child_iter); + child_tree.set (child_iter, Column.ITEM, item, -1); + + items.set (item, new NodeWrapper (child_tree, child_iter)); + + // This is equivalent to a property change. The tree still needs to update + // some of the new item's properties through this signal's handler. + item_updated (item); + + add_property_monitor (item); + + push_parent_update (parent); + + // If the item is expandable, also add children + var expandable = item as ExpandableItem; + if (expandable != null) { + foreach (var child_item in expandable.children) + add_item (child_item); + + // Monitor future additions/removals through signal handlers + add_children_monitor (expandable); + } + } + + private void remove_item (Item item) requires (has_item (item)) { + assert (root != null); + + remove_property_monitor (item); + + // get_item_child_iter() depends on items.get(item) for retrieving the right reference, + // so don't unset the item from @items yet! We first get the child iter and then + // unset the value. + var child_iter = get_item_child_iter (item); + + // Now we remove the item from the table, because that way get_item_child_iter() and + // all the methods that depend on it won't return invalid iters or items when + // called. This is important because child_tree.remove() will emit row_deleted(), + // and its handlers could potentially depend on one of the methods mentioned above. + items.unset (item); + + if (child_iter != null) + child_tree.remove (ref child_iter); + + push_parent_update (item.parent); + + // If the item is expandable, also remove children + var expandable = item as ExpandableItem; + if (expandable != null) { + // No longer monitor future additions or removals + remove_children_monitor (expandable); + + foreach (var child_item in expandable.children) + remove_item (child_item); + } + } + + private void add_property_monitor (Item item) { + var wrapper = new ItemMonitor (item); + monitors[item] = wrapper; + wrapper.changed.connect (on_item_prop_changed); + } + + private void remove_property_monitor (Item item) { + var wrapper = monitors[item]; + if (wrapper != null) + wrapper.changed.disconnect (on_item_prop_changed); + monitors.unset (item); + } + + private void add_children_monitor (ExpandableItem item) { + item.child_added.connect_after (on_item_child_added); + item.child_removed.connect_after (on_item_child_removed); + } + + private void remove_children_monitor (ExpandableItem item) { + item.child_added.disconnect (on_item_child_added); + item.child_removed.disconnect (on_item_child_removed); + } + + private void on_item_child_added (Item item) { + add_item (item); + } + + private void on_item_child_removed (Item item) { + remove_item (item); + } + + private void on_item_prop_changed (Item item, string prop_name) { + if (prop_name != "parent") + update_item (item); + } + + /** + * Pushes a call to update_item() if //parent// is not //null//. + * + * This is needed because the visibility of categories depends on their n_children property, + * and also because item expansion should be updated after adding or removing items. + * If many updates are pushed, and the item has still not been updated, only one is processed. + * This guarantees efficiency as updating a category item could trigger expensive actions. + */ + private void push_parent_update (ExpandableItem? parent) { + if (parent == null) + return; + + bool needs_update = parent.get_data (ITEM_PARENT_NEEDS_UPDATE); + + // If an update is already waiting to be processed, just return, as we + // don't need to queue another one for the same item. + if (needs_update) + return; + + var path = get_item_path (parent); + + if (path != null) { + // Let's mark this item for update + parent.set_data (ITEM_PARENT_NEEDS_UPDATE, true); + + Idle.add (() => { + if (parent != null) { + update_item (parent); + + // Already updated. No longer needs an update. + parent.set_data (ITEM_PARENT_NEEDS_UPDATE, false); + } + + return false; + }); + } + } + + /** + * Returns the Item pointed by iter, or null if the iter doesn't refer to a valid item. + */ + public Item? get_item (Gtk.TreeIter iter) { + Item? item; + get (iter, Column.ITEM, out item, -1); + return item; + } + + /** + * Returns the Item pointed by path, or null if the path doesn't refer to a valid item. + */ + public Item? get_item_from_path (Gtk.TreePath path) { + Gtk.TreeIter iter; + if (get_iter (out iter, path)) + return get_item (iter); + + return null; + } + + /** + * Returns a newly-created path pointing to the item, or null in case a valid path + * is not found. + */ + public Gtk.TreePath? get_item_path (Item item) { + Gtk.TreePath? path = null, child_path = get_item_child_path (item); + + // We want a filter path, not a child_model path + if (child_path != null) + path = convert_child_path_to_path (child_path); + + return path; + } + + /** + * Returns a newly-created iterator pointing to the item, or null in case a valid iter + * was not found. + */ + public Gtk.TreeIter? get_item_iter (Item item) { + var child_iter = get_item_child_iter (item); + + if (child_iter != null) { + Gtk.TreeIter iter; + if (convert_child_iter_to_iter (out iter, child_iter)) + return iter; + } + + return null; + } + + /** + * External "extra" filter method. + */ + public void set_filter_func (SourceList.VisibleFunc? visible_func) { + this.filter_func = visible_func; + } + + /** + * Checks whether an item is a category (i.e. a root-level expandable item). + * The caller must pass an iter or path pointing to the item, but not both + * (one of them must be null.) + * + * TODO: instead of checking the position of the iter or path, we should simply + * check whether the item's parent is the root item and whether the item is + * expandable. We don't do so right now because vala still allows client code + * to access the Item.parent property, even though its setter is defined as internal. + */ + public bool is_category (Item item, Gtk.TreeIter? iter, Gtk.TreePath? path = null) { + bool is_category = false; + // either iter or path has to be null + if (item is ExpandableItem) { + if (iter != null) { + assert (path == null); + is_category = is_iter_at_root_level (iter); + } else { + assert (iter == null); + is_category = is_path_at_root_level (path); + } + } + return is_category; + } + + public bool is_iter_at_root_level (Gtk.TreeIter iter) { + return is_path_at_root_level (get_path (iter)); + } + + public bool is_path_at_root_level (Gtk.TreePath path) { + return path.get_depth () == 1; + } + + private void resort () { + child_tree.set_sort_column_id (Gtk.SortColumn.UNSORTED, Gtk.SortType.ASCENDING); + child_tree.set_sort_column_id (Gtk.SortColumn.DEFAULT, Gtk.SortType.ASCENDING); + } + + private int child_model_sort_func (Gtk.TreeModel model, Gtk.TreeIter a, Gtk.TreeIter b) { + int order = 0; + + Item? item_a, item_b; + child_tree.get (a, Column.ITEM, out item_a, -1); + child_tree.get (b, Column.ITEM, out item_b, -1); + + // code should only compare items at same hierarchy level + assert (item_a.parent == item_b.parent); + + var parent = item_a.parent as SourceListSortable; + if (parent != null) + order = parent.compare (item_a, item_b); + + return order; + } + + private Gtk.TreeIter? get_item_child_iter (Item item) { + Gtk.TreeIter? child_iter = null; + + var child_node_wrapper = items.get (item); + if (child_node_wrapper != null) + child_iter = child_node_wrapper.iter; + + return child_iter; + } + + private Gtk.TreePath? get_item_child_path (Item item) { + Gtk.TreePath? child_path = null; + + var child_node_wrapper = items.get (item); + if (child_node_wrapper != null) + child_path = child_node_wrapper.path; + + return child_path; + } + + /** + * Filters the child-tree items based on their "visible" property. + */ + private bool filter_visible_func (Gtk.TreeModel child_model, Gtk.TreeIter iter) { + bool item_visible = false; + + Item? item; + child_tree.get (iter, Column.ITEM, out item, -1); + + if (item != null) { + item_visible = item.visible; + + // If the item is a category, also query the number of visible children + // because empty categories should not be displayed. + var expandable = item as ExpandableItem; + if (expandable != null && child_tree.iter_depth (iter) == 0) { + uint n_visible_children = 0; + foreach (var child_item in expandable.children) { + if (child_item.visible) + n_visible_children++; + } + item_visible = item_visible && n_visible_children > 0; + } + } + + if (filter_func != null) + item_visible = item_visible && filter_func (item); + + return item_visible; + } + + /** + * TreeDragDest implementation + */ + + public bool drag_data_received (Gtk.TreePath dest, Gtk.SelectionData selection_data) { + Gtk.TreeModel model; + Gtk.TreePath src_path; + + // Check if the user is dragging a row: + // + // Due to Gtk.TreeModelFilter's implementation of drag_data_get the values returned by + // tree_row_drag_data for GtkModel and GtkPath correspond to the child model and not the filter. + if (Gtk.tree_get_row_drag_data (selection_data, out model, out src_path) && model == child_tree) { + // get a child path representation of dest + var child_dest = convert_path_to_child_path (dest); + + if (child_dest != null) { + // New GtkTreeIters will be assigned to the rows at child_dest and its children. + if (child_tree_drag_data_received (child_dest, src_path)) + return true; + } + } + + // no new row inserted + return false; + } + + private bool child_tree_drag_data_received (Gtk.TreePath dest, Gtk.TreePath src_path) { + bool retval = false; + Gtk.TreeIter src_iter, dest_iter; + + if (!child_tree.get_iter (out src_iter, src_path)) + return false; + + var prev = dest; + + // Get the path to insert _after_ (dest is the path to insert _before_) + if (!prev.prev ()) { + // dest was the first spot at the current depth; which means + // we are supposed to prepend. + + var parent = dest; + Gtk.TreeIter? dest_parent = null; + + if (parent.up () && parent.get_depth () > 0) + child_tree.get_iter (out dest_parent, parent); + + child_tree.prepend (out dest_iter, dest_parent); + retval = true; + } else if (child_tree.get_iter (out dest_iter, prev)) { + var tmp_iter = dest_iter; + child_tree.insert_after (out dest_iter, null, tmp_iter); + retval = true; + } + + // If we succeeded in creating dest_iter, walk src_iter tree branch, + // duplicating it below dest_iter. + if (retval) { + recursive_node_copy (src_iter, dest_iter); + + // notify that the item was moved + Item item; + child_tree.get (src_iter, Column.ITEM, out item, -1); + return_val_if_fail (item != null, retval); + + // XXX Workaround: + // GtkTreeView automatically collapses expanded items that + // are dragged to a new location. Oddly, GtkTreeView doesn't fire + // 'row-collapsed' for the respective path, so we cannot keep track + // of that behavior via standard means. For now we'll just have + // our tree view check the properties of item again and ensure + // they're honored + update_item (item); + + var parent = item.parent as SourceListSortable; + return_val_if_fail (parent != null, retval); + + parent.user_moved_item (item); + } + + return retval; + } + + private void recursive_node_copy (Gtk.TreeIter src_iter, Gtk.TreeIter dest_iter) { + move_item (src_iter, dest_iter); + + Gtk.TreeIter child; + if (child_tree.iter_children (out child, src_iter)) { + // Need to create children and recurse. Note our dependence on + // persistent iterators here. + do { + Gtk.TreeIter copy; + child_tree.append (out copy, dest_iter); + recursive_node_copy (child, copy); + } while (child_tree.iter_next (ref child)); + } + } + + private void move_item (Gtk.TreeIter src_iter, Gtk.TreeIter dest_iter) { + Item item; + child_tree.get (src_iter, Column.ITEM, out item, -1); + return_if_fail (item != null); + + // update the row reference of item with the new location + child_tree.set (dest_iter, Column.ITEM, item, -1); + items.set (item, new NodeWrapper (child_tree, dest_iter)); + } + + public bool row_drop_possible (Gtk.TreePath dest, Gtk.SelectionData selection_data) { + Gtk.TreeModel model; + Gtk.TreePath src_path; + + // Check if the user is dragging a row: + // Due to Gtk.TreeModelFilter's implementation of drag_data_get the values returned by + // tree_row_drag_data for GtkModel and GtkPath correspond to the child model and not the filter. + if (!Gtk.tree_get_row_drag_data (selection_data, out model, out src_path) || model != child_tree) + return false; + + // get a representation of dest in the child model + var child_dest = convert_path_to_child_path (dest); + + // don't allow dropping an item into itself + if (child_dest == null || src_path.compare (child_dest) == 0) + return false; + + // Only allow DnD between items at the same depth (indentation level) + // This doesn't mean their parent is the same. + int src_depth = src_path.get_depth (); + int dest_depth = child_dest.get_depth (); + + if (src_depth != dest_depth) + return false; + + // no need to check dest_depth since we know its equal to src_depth + if (src_depth < 1) + return false; + + Item? parent = null; + + // if the depth is 1, we're talking about the items at root level, + // and by definition they share the same parent (root). We don't + // need to verify anything else for that specific case + if (src_depth == 1) { + parent = root; + } else { + // we verified equality above. this must be true + assert (dest_depth > 1); + + // Only allow reordering between siblings, i.e. items with the same + // parent. We don't want items to change their parent through DnD + // because that would complicate our existing APIs, and may introduce + // unpredictable behavior. + var src_indices = src_path.get_indices (); + var dest_indices = child_dest.get_indices (); + + // parent index is given by indices[depth-2], where depth > 1 + int src_parent_index = src_indices[src_depth - 2]; + int dest_parent_index = dest_indices[dest_depth - 2]; + + if (src_parent_index != dest_parent_index) + return false; + + // get parent. Note that we don't use the child path for this + var dest_parent = dest; + + if (!dest_parent.up () || dest_parent.get_depth () < 1) + return false; + + parent = get_item_from_path (dest_parent); + } + + var sortable = parent as SourceListSortable; + + if (sortable == null || !sortable.allow_dnd_sorting ()) + return false; + + var dest_item = get_item_from_path (dest); + + if (dest_item == null) + return true; + + Item? source_item = null; + var filter_src_path = convert_child_path_to_path (src_path); + + if (filter_src_path != null) + source_item = get_item_from_path (filter_src_path); + + if (source_item == null) + return false; + + // If order isn't indifferent (=0), 'dest' has to sort before 'source'. + // Otherwise we'd allow the user to move the 'source_item' to a new + // location before 'dest_item', but that location would be changed + // later by the sort function, making the whole interaction poinless. + // We better prevent such reorderings from the start by giving the + // user a visual clue about the invalid drop location. + if (sortable.compare (dest_item, source_item) >= 0) { + if (!dest.prev ()) + return true; + + // 'source_item' also has to sort 'after' or 'equal' the item currently + // preceding 'dest_item' + var dest_item_prev = get_item_from_path (dest); + + return dest_item_prev != null + && dest_item_prev != source_item + && sortable.compare (dest_item_prev, source_item) <= 0; + } + + return false; + } + + /** + * Override default implementation of TreeDragSource + * + * drag_data_delete is not overriden because the default implementation + * does exactly what we need. + */ + + public bool drag_data_get (Gtk.TreePath path, Gtk.SelectionData selection_data) { + // If we're asked for a data about a row, just have the default implementation fill in + // selection_data. Please note that it will provide information relative to child_model. + if (selection_data.get_target () == Gdk.Atom.intern_static_string ("GTK_TREE_MODEL_ROW")) + return base.drag_data_get (path, selection_data); + + // check if the item at path provides DnD source data + var drag_source_item = get_item_from_path (path) as SourceListDragSource; + if (drag_source_item != null && drag_source_item.draggable ()) { + drag_source_item.prepare_selection_data (selection_data); + return true; + } + + return false; + } + + public bool row_draggable (Gtk.TreePath path) { + if (!base.row_draggable (path)) + return false; + + var item = get_item_from_path (path); + + if (item != null) { + // check if the item's parent allows DnD sorting + var sortable_item = item.parent as SourceListSortable; + + if (sortable_item != null && sortable_item.allow_dnd_sorting ()) + return true; + + // Since the parent item does not allow DnD sorting, there's no + // reason to allow dragging it unless the row is actually draggable. + var drag_source_item = item as SourceListDragSource; + + if (drag_source_item != null && drag_source_item.draggable ()) + return true; + } + + return false; + } + } + + + /** + * Class responsible for rendering Item.icon and Item.activatable. It also + * notifies about clicks through the activated() signal. + */ + private class CellRendererIcon : Gtk.CellRendererPixbuf { + public signal void activated (string path); + + private const Gtk.IconSize ICON_SIZE = Gtk.IconSize.MENU; + + public CellRendererIcon () { + + } + + construct { + mode = Gtk.CellRendererMode.ACTIVATABLE; + stock_size = ICON_SIZE; + } + + public override bool activate ( + Gdk.Event event, + Gtk.Widget widget, + string path, + Gdk.Rectangle background_area, + Gdk.Rectangle cell_area, + Gtk.CellRendererState flags + ) { + activated (path); + return true; + } + } + + + + /** + * A cell renderer that only adds space. + */ + private class CellRendererSpacer : Gtk.CellRenderer { + /** + * Indentation level represented by this cell renderer + */ + public int level { get; set; default = -1; } + + public override Gtk.SizeRequestMode get_request_mode () { + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void get_preferred_width (Gtk.Widget widget, out int min_size, out int natural_size) { + min_size = natural_size = 2 * (int) xpad; + } + + public override void get_preferred_height_for_width ( + Gtk.Widget widget, + int width, + out int min_height, + out int natural_height + ) { + min_height = natural_height = 2 * (int) ypad; + } + + public override void render ( + Cairo.Context context, + Gtk.Widget widget, + Gdk.Rectangle bg_area, + Gdk.Rectangle cell_area, + Gtk.CellRendererState flags + ) { + // Nothing to do. This renderer only adds space. + } + } + + + + /** + * The tree that actually displays the items. + * + * All the user interaction happens here. + */ + private class Tree : Gtk.TreeView { + + public DataModel data_model { get; construct set; } + + public signal void item_selected (Item? item); + + public Item? selected_item { + get { return selected; } + set { set_selected (value, true); } + } + + public bool editing { + get { return text_cell.editing; } + } + + public Pango.EllipsizeMode ellipsize_mode { + get { return text_cell.ellipsize; } + set { text_cell.ellipsize = value; } + } + + private enum Column { + ITEM, + N_COLS + } + + private Item? selected; + private unowned Item? edited; + + private Gtk.Entry? editable_entry; + private Gtk.CellRendererText text_cell; + private CellRendererIcon icon_cell; + private CellRendererIcon activatable_cell; + private CellRendererBadge badge_cell; + private CellRendererExpander primary_expander_cell; + private CellRendererExpander secondary_expander_cell; + private Gee.HashMap spacer_cells; // cells used for left spacing + private bool unselectable_item_clicked = false; + + private const string DEFAULT_STYLESHEET = """ + .sidebar.badge { + border-radius: 10px; + border-width: 0; + padding: 1px 2px 1px 2px; + font-weight: bold; + } + """; + + private const string STYLE_PROP_LEVEL_INDENTATION = "level-indentation"; + private const string STYLE_PROP_LEFT_PADDING = "left-padding"; + private const string STYLE_PROP_EXPANDER_SPACING = "expander-spacing"; + + static construct { + install_style_property (new ParamSpecInt ( + STYLE_PROP_LEVEL_INDENTATION, + "Level Indentation", + "Space to add at the beginning of every indentation level. Must be an even number.", + 1, + 50, + 6, + ParamFlags.READABLE + )); + + install_style_property (new ParamSpecInt ( + STYLE_PROP_LEFT_PADDING, + "Left Padding", + "Padding added to the left side of the tree. Must be an even number.", + 1, + 50, + 4, + ParamFlags.READABLE + )); + + install_style_property (new ParamSpecInt ( + STYLE_PROP_EXPANDER_SPACING, + "Expander Spacing", + "Space added between an item and its expander. Must be an even number.", + 1, + 50, + 4, + ParamFlags.READABLE + )); + } + + public Tree (DataModel data_model) { + Object (data_model: data_model); + } + + construct { + unowned Gtk.StyleContext style_context = get_style_context (); + style_context.add_class (Gtk.STYLE_CLASS_SIDEBAR); + style_context.add_class ("source-list"); + + var css_provider = new Gtk.CssProvider (); + try { + css_provider.load_from_data (DEFAULT_STYLESHEET, -1); + style_context.add_provider (css_provider, Gtk.STYLE_PROVIDER_PRIORITY_FALLBACK); + } catch (Error e) { + warning ("Could not create CSS Provider: %s\nStylesheet:\n%s", e.message, DEFAULT_STYLESHEET); + } + + set_model (data_model); + + halign = valign = Gtk.Align.FILL; + expand = true; + + enable_search = false; + headers_visible = false; + enable_grid_lines = Gtk.TreeViewGridLines.NONE; + + // Deactivate GtkTreeView's built-in expander functionality + expander_column = null; + show_expanders = false; + + var item_column = new Gtk.TreeViewColumn (); + item_column.expand = true; + + insert_column (item_column, Column.ITEM); + + // Now pack the cell renderers. We insert them in reverse order (using pack_end) + // because we want to use TreeViewColumn.pack_start exclusively for inserting + // spacer cell renderers for level-indentation purposes. + // See add_spacer_cell_for_level() for more details. + + // Second expander. Used for main categories + secondary_expander_cell = new CellRendererExpander (); + secondary_expander_cell.is_category_expander = true; + secondary_expander_cell.xpad = 10; + item_column.pack_end (secondary_expander_cell, false); + item_column.set_cell_data_func (secondary_expander_cell, expander_cell_data_func); + + activatable_cell = new CellRendererIcon (); + activatable_cell.xpad = 6; + activatable_cell.activated.connect (on_activatable_activated); + item_column.pack_end (activatable_cell, false); + item_column.set_cell_data_func (activatable_cell, icon_cell_data_func); + + badge_cell = new CellRendererBadge (); + badge_cell.xpad = 1; + badge_cell.xalign = 1; + item_column.pack_end (badge_cell, false); + item_column.set_cell_data_func (badge_cell, badge_cell_data_func); + + text_cell = new Gtk.CellRendererText (); + text_cell.editable_set = true; + text_cell.editable = false; + text_cell.editing_started.connect (on_editing_started); + text_cell.editing_canceled.connect (on_editing_canceled); + text_cell.ellipsize = Pango.EllipsizeMode.END; + text_cell.xalign = 0; + item_column.pack_end (text_cell, true); + item_column.set_cell_data_func (text_cell, name_cell_data_func); + + icon_cell = new CellRendererIcon (); + icon_cell.xpad = 2; + item_column.pack_end (icon_cell, false); + item_column.set_cell_data_func (icon_cell, icon_cell_data_func); + + // First expander. Used for normal expandable items + primary_expander_cell = new CellRendererExpander (); + + int expander_spacing; + style_get (STYLE_PROP_EXPANDER_SPACING, out expander_spacing); + primary_expander_cell.xpad = expander_spacing / 2; + + item_column.pack_end (primary_expander_cell, false); + item_column.set_cell_data_func (primary_expander_cell, expander_cell_data_func); + + // Selection + var selection = get_selection (); + selection.mode = Gtk.SelectionMode.BROWSE; + selection.set_select_function (select_func); + + // Monitor item changes + enable_item_property_monitor (); + + // Add root-level indentation. New levels will be added by update_item_expansion() + add_spacer_cell_for_level (1); + + // Enable basic row drag and drop + configure_drag_source (null); + configure_drag_dest (null, 0); + + query_tooltip.connect_after (on_query_tooltip); + has_tooltip = true; + } + + ~Tree () { + disable_item_property_monitor (); + } + + public override bool drag_motion (Gdk.DragContext context, int x, int y, uint time) { + // call the base signal to get rows with children to spring open + if (!base.drag_motion (context, x, y, time)) + return false; + + Gtk.TreePath suggested_path, current_path; + Gtk.TreeViewDropPosition suggested_pos, current_pos; + + if (get_dest_row_at_pos (x, y, out suggested_path, out suggested_pos)) { + // the base implementation of drag_motion was likely to set a drop + // destination row. If that's the case, we configure the row position + // to only allow drops before or after it, but not into it + get_drag_dest_row (out current_path, out current_pos); + + if (current_path != null && suggested_path.compare (current_path) == 0) { + // If the source widget is this treeview, we assume we're + // just dragging rows around, because at the moment dragging + // rows into other rows (re-parenting) is not implemented. + var source_widget = Gtk.drag_get_source_widget (context); + bool dragging_treemodel_row = (source_widget == this); + + if (dragging_treemodel_row) { + // we don't allow DnD into other rows, only in between them + // (no row is highlighted) + if (current_pos != Gtk.TreeViewDropPosition.BEFORE) { + if (current_pos == Gtk.TreeViewDropPosition.INTO_OR_BEFORE) + set_drag_dest_row (current_path, Gtk.TreeViewDropPosition.BEFORE); + else + set_drag_dest_row (null, Gtk.TreeViewDropPosition.AFTER); + } + } else { + // for DnD originated on a different widget, we don't want to insert + // between rows, only select the rows themselves + if (current_pos == Gtk.TreeViewDropPosition.BEFORE) + set_drag_dest_row (current_path, Gtk.TreeViewDropPosition.INTO_OR_BEFORE); + else if (current_pos == Gtk.TreeViewDropPosition.AFTER) + set_drag_dest_row (current_path, Gtk.TreeViewDropPosition.INTO_OR_AFTER); + + // determine if external DnD is supported by the item at destination + var dest = data_model.get_item_from_path (current_path) as SourceListDragDest; + + if (dest != null) { + var target_list = Gtk.drag_dest_get_target_list (this); + var target = Gtk.drag_dest_find_target (this, context, target_list); + + // have 'drag_get_data' call 'drag_data_received' to determine + // if the data can actually be dropped. + context.set_data ("suggested-dnd-action", context.get_suggested_action ()); + Gtk.drag_get_data (this, context, target, time); + } else { + // dropping data here is not supported. Unset dest row + set_drag_dest_row (null, Gtk.TreeViewDropPosition.BEFORE); + } + } + } + } else { + // dropping into blank areas of SourceList is not allowed + set_drag_dest_row (null, Gtk.TreeViewDropPosition.AFTER); + return false; + } + + return true; + } + + public override void drag_data_received ( + Gdk.DragContext context, + int x, + int y, + Gtk.SelectionData selection_data, + uint info, + uint time + ) { + var target_list = Gtk.drag_dest_get_target_list (this); + var target = Gtk.drag_dest_find_target (this, context, target_list); + + if (target == Gdk.Atom.intern_static_string ("GTK_TREE_MODEL_ROW")) { + base.drag_data_received (context, x, y, selection_data, info, time); + return; + } + + Gtk.TreePath path; + Gtk.TreeViewDropPosition pos; + + if (context.get_data ("suggested-dnd-action") != 0) { + context.set_data ("suggested-dnd-action", 0); + + get_drag_dest_row (out path, out pos); + + if (path != null) { + // determine if external DnD is allowed by the item at destination + var dest = data_model.get_item_from_path (path) as SourceListDragDest; + + if (dest == null || !dest.data_drop_possible (context, selection_data)) { + // dropping data here is not allowed. unset any previously + // selected destination row + set_drag_dest_row (null, Gtk.TreeViewDropPosition.BEFORE); + Gdk.drag_status (context, 0, time); + return; + } + } + + Gdk.drag_status (context, context.get_suggested_action (), time); + } else { + if (get_dest_row_at_pos (x, y, out path, out pos)) { + // Data coming from external source/widget was dropped into this item. + // selection_data contains something other than a tree row; most likely + // we're dealing with a DnD not originated within the Source List tree. + // Let's pass the data to the corresponding item, if there's a handler. + + var drag_dest = data_model.get_item_from_path (path) as SourceListDragDest; + + if (drag_dest != null) { + var action = drag_dest.data_received (context, selection_data); + Gtk.drag_finish (context, action != 0, action == Gdk.DragAction.MOVE, time); + return; + } + } + + // failure + Gtk.drag_finish (context, false, false, time); + } + } + + public void configure_drag_source (Gtk.TargetEntry[]? src_entries) { + // Append GTK_TREE_MODEL_ROW to src_entries and src_entries to enable row DnD. + var entries = append_row_target_entry (src_entries); + + unset_rows_drag_source (); + enable_model_drag_source (Gdk.ModifierType.BUTTON1_MASK, entries, Gdk.DragAction.MOVE); + } + + public void configure_drag_dest (Gtk.TargetEntry[]? dest_entries, Gdk.DragAction actions) { + // Append GTK_TREE_MODEL_ROW to dest_entries and dest_entries to enable row DnD. + var entries = append_row_target_entry (dest_entries); + + unset_rows_drag_dest (); + + // DragAction.MOVE needs to be enabled for row drag-and-drop to work properly + enable_model_drag_dest (entries, Gdk.DragAction.MOVE | actions); + } + + private bool on_query_tooltip (int x, int y, bool keyboard_tooltip, Gtk.Tooltip tooltip) { + Gtk.TreePath path; + Gtk.TreeViewColumn column = get_column (Column.ITEM); + + get_tooltip_context (ref x, ref y, keyboard_tooltip, null, out path, null); + if (path == null) { + return false; + } + + var item = data_model.get_item_from_path (path); + if (item != null) { + bool should_show = false; + + Gdk.Rectangle start_cell_area; + get_cell_area (path, column, out start_cell_area); + + set_tooltip_row (tooltip, path); + + if (item.tooltip == null) { + tooltip.set_markup (item.name); + should_show = true; + } else if (item.tooltip != "") { + tooltip.set_markup (item.tooltip); + should_show = true; + } + + if (keyboard_tooltip) { + return should_show; + } + + if (over_cell (column, path, text_cell, x - start_cell_area.x) || + over_cell (column, path, icon_cell, x - start_cell_area.x)) { + + return should_show; + } else if (over_cell (column, path, activatable_cell, x - start_cell_area.x)) { + if (item.activatable_tooltip == "") { + return false; + } else { + tooltip.set_markup (item.activatable_tooltip); + return true; + } + } + } + + return false; + } + + private static Gtk.TargetEntry[] append_row_target_entry (Gtk.TargetEntry[]? orig) { + const Gtk.TargetEntry row_target_entry = { // vala-lint=naming-convention + "GTK_TREE_MODEL_ROW", + Gtk.TargetFlags.SAME_WIDGET, + 0 + }; + + var entries = new Gtk.TargetEntry[0]; + entries += row_target_entry; + + if (orig != null) { + foreach (var target_entry in orig) + entries += target_entry; + } + + return entries; + } + + private void enable_item_property_monitor () { + data_model.item_updated.connect_after (on_model_item_updated); + } + + private void disable_item_property_monitor () { + data_model.item_updated.disconnect (on_model_item_updated); + } + + private void on_model_item_updated (Item item) { + // Currently, all the other properties are updated automatically by the + // cell-data functions after a change in the model. + var expandable_item = item as ExpandableItem; + if (expandable_item != null) + update_expansion (expandable_item); + } + + private void add_spacer_cell_for_level ( + int level, + bool check_previous = true + ) requires (level > 0) { + if (spacer_cells == null) + spacer_cells = new Gee.HashMap (); + + if (!spacer_cells.has_key (level)) { + var spacer_cell = new CellRendererSpacer (); + spacer_cell.level = level; + spacer_cells[level] = spacer_cell; + + uint cell_xpadding; + + // The primary expander is not visible for root-level (i.e. first level) + // items, so for the second level of indentation we use a low padding + // because the primary expander will add enough space. For the root level, + // we use left_padding, and level_indentation for the remaining levels. + // The value of cell_xpadding will be allocated *twice* by the cell renderer, + // so we set the value to a half of actual (desired) value. + switch (level) { + case 1: // root + int left_padding; + style_get (STYLE_PROP_LEFT_PADDING, out left_padding); + cell_xpadding = left_padding / 2; + break; + + case 2: // second level + cell_xpadding = 0; + break; + + default: // remaining levels + int level_indentation; + style_get (STYLE_PROP_LEVEL_INDENTATION, out level_indentation); + cell_xpadding = level_indentation / 2; + break; + } + + spacer_cell.xpad = cell_xpadding; + + var item_column = get_column (Column.ITEM); + item_column.pack_start (spacer_cell, false); + item_column.set_cell_data_func (spacer_cell, spacer_cell_data_func); + + // Make sure that the previous indentation levels also exist + if (check_previous) { + for (int i = level - 1; i > 0; i--) + add_spacer_cell_for_level (i, false); + } + } + } + + /** + * Evaluates whether the item at the specified path can be selected or not. + */ + private bool select_func ( + Gtk.TreeSelection selection, + Gtk.TreeModel model, + Gtk.TreePath path, + bool path_currently_selected + ) { + bool selectable = false; + var item = data_model.get_item_from_path (path); + + if (item != null) { + // Main categories ARE NOT selectable, so check for that + if (!data_model.is_category (item, null, path)) + selectable = item.selectable; + } + + return selectable; + } + + private Gtk.TreePath? get_selected_path () { + Gtk.TreePath? selected_path = null; + Gtk.TreeSelection? selection = get_selection (); + + if (selection != null) { + Gtk.TreeModel? model; + var selected_rows = selection.get_selected_rows (out model); + if (selected_rows.length () == 1) + selected_path = selected_rows.nth_data (0); + } + + return selected_path; + } + + private void set_selected (Item? item, bool scroll_to_item) { + if (item == null) { + Gtk.TreeSelection? selection = get_selection (); + if (selection != null) + selection.unselect_all (); + + // As explained in cursor_changed(), we cannot emit signals for this special + // case from there because that wouldn't allow us to implement the behavior + // we want (i.e. restoring the old selection after expanding a previously + // collapsed category) without emitting the undesired item_selected() signal + // along the way. This special case is handled manually, because it *should* + // only happen in response to client code requests and never in response to + // user interaction. We do that here because there's no way to determine + // whether the cursor change came from code (i.e. this method) or user + // interaction from cursor_changed(). + this.selected = null; + item_selected (null); + } else if (item.selectable) { + if (scroll_to_item) + this.scroll_to_item (item); + + var to_select = data_model.get_item_path (item); + if (to_select != null) + set_cursor_on_cell (to_select, get_column (Column.ITEM), text_cell, false); + } + } + + public override void cursor_changed () { + var path = get_selected_path (); + Item? new_item = path != null ? data_model.get_item_from_path (path) : null; + + // Don't do anything if @new_item is null. + // + // The only way 'this.selected' can be null is by setting it explicitly to + // that value from client code, and thus we handle that case in set_selected(). + // THIS CANNOT HAPPEN IN RESPONSE TO USER INTERACTION. For example, if an + // item is un-selected because its parent category has been collapsed, then it will + // remain as the current selected item (not in reality, just as the value of + // this.selected) and will be re-selected after the parent is expanded again. + // THIS ALL HAPPENS SILENTLY BEHIND THE SCENES, so client code will never know + // it ever happened; the value of selected_item remains unchanged and item_selected() + // is not emitted. + if (new_item != null && new_item != this.selected) { + this.selected = new_item; + item_selected (new_item); + } + } + + public bool scroll_to_item (Item item, bool use_align = false, float row_align = 0) { + bool scrolled = false; + + var path = data_model.get_item_path (item); + if (path != null) { + scroll_to_cell (path, null, use_align, row_align, 0); + scrolled = true; + } + + return scrolled; + } + + public bool start_editing_item (Item item) requires (item.editable) requires (item.selectable) { + if (editing && item == edited) // If same item again, simply return. + return false; + + var path = data_model.get_item_path (item); + if (path != null) { + edited = item; + text_cell.editable = true; + set_cursor_on_cell (path, get_column (Column.ITEM), text_cell, true); + } else { + warning ("Could not edit \"%s\": path not found", item.name); + } + + return editing; + } + + public void stop_editing () { + if (editing && edited != null) { + var path = data_model.get_item_path (edited); + + // Setting the cursor on the same cell without starting an edit cancels any + // editing operation going on. + if (path != null) + set_cursor_on_cell (path, get_column (Column.ITEM), text_cell, false); + } + } + + private void on_editing_started (Gtk.CellEditable editable, string path) { + editable_entry = editable as Gtk.Entry; + if (editable_entry != null) { + editable_entry.editing_done.connect (on_editing_done); + editable_entry.editable = true; + } + } + + private void on_editing_canceled () { + if (editable_entry != null) { + editable_entry.editable = false; + editable_entry.editing_done.disconnect (on_editing_done); + } + + text_cell.editable = false; + edited = null; + } + + private void on_editing_done () { + if (edited != null && edited.editable && editable_entry != null) + edited.edited (editable_entry.get_text ()); + + // Same actions as when canceling editing + on_editing_canceled (); + } + + private void on_activatable_activated (string item_path_str) { + var item = get_item_from_path_string (item_path_str); + if (item != null) + item.action_activated (); + } + + private Item? get_item_from_path_string (string item_path_str) { + var item_path = new Gtk.TreePath.from_string (item_path_str); + return data_model.get_item_from_path (item_path); + } + + private bool toggle_expansion (ExpandableItem item) { + if (item.collapsible) { + item.expanded = !item.expanded; + return true; + } + return false; + } + + /** + * Updates the tree to reflect the ''expanded'' property of expandable_item. + */ + public void update_expansion (ExpandableItem expandable_item) { + var path = data_model.get_item_path (expandable_item); + + if (path != null) { + // Make sure that the indentation cell for the item's level exists. + // We use +1 because the method will make sure that the previous + // indentation levels exist too. + add_spacer_cell_for_level (path.get_depth () + 1); + + if (expandable_item.expanded) { + expand_row (path, false); + + // Since collapsing an item un-selects any child item previously selected, + // we need to restore the selection. This will be done silently because + // set_selected checks for equality between the previously "selected" + // item and the newly selected, and only emits the item_selected() signal + // if they are different. See cursor_changed() for a better explanation + // of this behavior. + if (selected != null && selected.parent == expandable_item) + set_selected (selected, true); + + // Collapsing expandable_item's row also collapsed all its children, + // and thus we need to update the "expanded" property of each of them + // to reflect their previous state. + foreach (var child_item in expandable_item.children) { + var child_expandable_item = child_item as ExpandableItem; + if (child_expandable_item != null) + update_expansion (child_expandable_item); + } + } else { + collapse_row (path); + } + } + } + + public override void row_expanded (Gtk.TreeIter iter, Gtk.TreePath path) { + var item = data_model.get_item (iter) as ExpandableItem; + return_if_fail (item != null); + + disable_item_property_monitor (); + item.expanded = true; + enable_item_property_monitor (); + } + + public override void row_collapsed (Gtk.TreeIter iter, Gtk.TreePath path) { + var item = data_model.get_item (iter) as ExpandableItem; + return_if_fail (item != null); + + disable_item_property_monitor (); + item.expanded = false; + enable_item_property_monitor (); + } + + public override void row_activated (Gtk.TreePath path, Gtk.TreeViewColumn column) { + if (column == get_column (Column.ITEM)) { + var item = data_model.get_item_from_path (path); + if (item != null) + item.activated (); + } + } + + public override bool key_release_event (Gdk.EventKey event) { + if (selected_item != null) { + switch (event.keyval) { + case Gdk.Key.F2: + var modifiers = Gtk.accelerator_get_default_mod_mask (); + // try to start editing selected item + if ((event.state & modifiers) == 0 && selected_item.editable) + start_editing_item (selected_item); + break; + } + } + + return base.key_release_event (event); + } + + public override bool button_release_event (Gdk.EventButton event) { + if (unselectable_item_clicked && event.window == get_bin_window ()) { + unselectable_item_clicked = false; + + Gtk.TreePath path; + Gtk.TreeViewColumn column; + int x = (int) event.x, y = (int) event.y, cell_x, cell_y; + + if (get_path_at_pos (x, y, out path, out column, out cell_x, out cell_y)) { + var item = data_model.get_item_from_path (path) as ExpandableItem; + + if (item != null) { + if (!item.selectable || data_model.is_category (item, null, path)) + toggle_expansion (item); + } + } + } + + return base.button_release_event (event); + } + + public override bool button_press_event (Gdk.EventButton event) { + if (event.window != get_bin_window ()) + return base.button_press_event (event); + + Gtk.TreePath path; + Gtk.TreeViewColumn column; + int x = (int) event.x, y = (int) event.y, cell_x, cell_y; + + if (get_path_at_pos (x, y, out path, out column, out cell_x, out cell_y)) { + var item = data_model.get_item_from_path (path); + + // This is needed because the treeview adds an offset at the beginning of every level + Gdk.Rectangle start_cell_area; + get_cell_area (path, get_column (0), out start_cell_area); + cell_x -= start_cell_area.x; + + if (item != null && column == get_column (Column.ITEM)) { + // Cancel any editing operation going on + stop_editing (); + + if (event.button == Gdk.BUTTON_SECONDARY) { + popup_context_menu (item, event); + return true; + } else if (event.button == Gdk.BUTTON_PRIMARY) { + // Check whether an expander (or an equivalent area) was clicked. + bool is_expandable = item is ExpandableItem; + bool is_category = is_expandable && data_model.is_category (item, null, path); + + if (event.type == Gdk.EventType.BUTTON_PRESS) { + if (is_expandable) { + // Checking for secondary_expander_cell is not necessary because the entire row + // serves for this purpose when the item is a category or when the item is a + // normal expandable item that is not selectable (special care is taken to + // not break the activatable/action icons for such cases). + // The expander only works like a visual indicator for these items. + unselectable_item_clicked = is_category + || (!item.selectable && !over_cell (column, path, activatable_cell, cell_x)); + + if (!unselectable_item_clicked + && over_primary_expander (column, path, cell_x) + && toggle_expansion (item as ExpandableItem)) + return true; + } + } else if ( + event.type == Gdk.EventType.2BUTTON_PRESS + && !is_category // Main categories are *not* editable + && item.editable + && item.selectable + && over_cell (column, path, text_cell, cell_x) + && start_editing_item (item) + ) { + // The user double-clicked over the text cell, and editing started successfully. + return true; + } + } + } + } + + return base.button_press_event (event); + } + + private bool over_primary_expander (Gtk.TreeViewColumn col, Gtk.TreePath path, int x) { + Gtk.TreeIter iter; + if (!model.get_iter (out iter, path)) + return false; + + // Call the cell-data function and make it assign the proper visibility state to the cell + expander_cell_data_func (col, primary_expander_cell, model, iter); + + if (!primary_expander_cell.visible) + return false; + + // We want to return false if the cell is not expandable (i.e. the arrow is hidden) + if (model.iter_n_children (iter) < 1) + return false; + + // Now that we're sure that the item is expandable, let's see if the user clicked + // over the expander area. We don't do so directly by querying the primary expander + // position because it's not fixed, yielding incorrect coordinates depending on whether + // a different area was re-drawn before this method was called. We know that the last + // spacer cell precedes (in a LTR fashion) the expander cell. Because the position + // of the spacer cell is fixed, we can safely query it. + int indentation_level = path.get_depth (); + var last_spacer_cell = spacer_cells[indentation_level]; + + if (last_spacer_cell != null) { + int cell_x, cell_width; + + if (col.cell_get_position (last_spacer_cell, out cell_x, out cell_width)) { + // Add a pixel so that the expander area is a bit wider + int expander_width = get_cell_width (primary_expander_cell) + 1; + + var dir = get_direction (); + if (dir == Gtk.TextDirection.NONE) { + dir = Gtk.Widget.get_default_direction (); + } + + if (dir == Gtk.TextDirection.LTR) { + int indentation_offset = cell_x + cell_width; + return x >= indentation_offset && x <= indentation_offset + expander_width; + } + + return x <= cell_x && x >= cell_x - expander_width; + } + } + + return false; + } + + private bool over_cell (Gtk.TreeViewColumn col, Gtk.TreePath path, Gtk.CellRenderer cell, int x) { + int cell_x, cell_width; + bool found = col.cell_get_position (cell, out cell_x, out cell_width); + return found && x > cell_x && x < cell_x + cell_width; + } + + private int get_cell_width (Gtk.CellRenderer cell_renderer) { + Gtk.Requisition min_req; + cell_renderer.get_preferred_size (this, out min_req, null); + return min_req.width; + } + + public override bool popup_menu () { + return popup_context_menu (null, null); + } + + private bool popup_context_menu (Item? item, Gdk.EventButton? event) { + if (item == null) + item = selected_item; + + if (item != null) { + var menu = item.get_context_menu (); + if (menu != null) { + menu.attach_widget = this; + menu.popup_at_pointer (event); + if (event == null) { + menu.select_first (false); + } + + return true; + } + } + + return false; + } + + private static Item? get_item_from_model (Gtk.TreeModel model, Gtk.TreeIter iter) { + var data_model = model as DataModel; + assert (data_model != null); + return data_model.get_item (iter); + } + + private static void spacer_cell_data_func ( + Gtk.CellLayout layout, + Gtk.CellRenderer renderer, + Gtk.TreeModel model, + Gtk.TreeIter iter + ) { + var spacer = renderer as CellRendererSpacer; + assert (spacer != null); + assert (spacer.level > 0); + + var path = model.get_path (iter); + + int level = -1; + if (path != null) + level = path.get_depth (); + + renderer.visible = spacer.level <= level; + } + + private void name_cell_data_func ( + Gtk.CellLayout layout, + Gtk.CellRenderer renderer, + Gtk.TreeModel model, + Gtk.TreeIter iter + ) { + var text_renderer = renderer as Gtk.CellRendererText; + assert (text_renderer != null); + + var text = new StringBuilder (); + var weight = Pango.Weight.NORMAL; + bool use_markup = false; + + var item = get_item_from_model (model, iter); + if (item != null) { + if (item.markup != null) { + text.append (item.markup); + use_markup = true; + } else { + text.append (item.name); + } + + if (data_model.is_category (item, iter)) + weight = Pango.Weight.BOLD; + } + + text_renderer.weight = weight; + if (use_markup) { + text_renderer.markup = text.str; + } else { + text_renderer.text = text.str; + } + } + + private void badge_cell_data_func ( + Gtk.CellLayout layout, + Gtk.CellRenderer renderer, + Gtk.TreeModel model, + Gtk.TreeIter iter + ) { + var badge_renderer = renderer as CellRendererBadge; + assert (badge_renderer != null); + + string text = ""; + bool visible = false; + + var item = get_item_from_model (model, iter); + if (item != null) { + // Badges are not displayed for main categories + visible = !data_model.is_category (item, iter) + && item.badge != null + && item.badge.strip () != ""; + + if (visible) + text = item.badge; + } + + badge_renderer.visible = visible; + badge_renderer.text = text; + } + + private void icon_cell_data_func ( + Gtk.CellLayout layout, + Gtk.CellRenderer renderer, + Gtk.TreeModel model, Gtk.TreeIter iter + ) { + var icon_renderer = renderer as CellRendererIcon; + assert (icon_renderer != null); + + bool visible = false; + Icon? icon = null; + + var item = get_item_from_model (model, iter); + if (item != null) { + // Icons are not displayed for main categories + visible = !data_model.is_category (item, iter); + + if (visible) { + if (icon_renderer == icon_cell) + icon = item.icon; + else if (icon_renderer == activatable_cell) + icon = item.activatable; + else + assert_not_reached (); + } + } + + visible = visible && icon != null; + + icon_renderer.visible = visible; + icon_renderer.gicon = visible ? icon : null; + } + + /** + * Controls expander visibility. + */ + private void expander_cell_data_func ( + Gtk.CellLayout layout, + Gtk.CellRenderer renderer, + Gtk.TreeModel model, + Gtk.TreeIter iter + ) { + var item = get_item_from_model (model, iter); + if (item != null) { + // Gtk.CellRenderer.is_expander takes into account whether the item has children or not. + // The tree-view checks for that and sets this property for us. It also sets + // Gtk.CellRenderer.is_expanded, and thus we don't need to check for that either. + var expandable_item = item as ExpandableItem; + if (expandable_item != null) + renderer.is_expander = renderer.is_expander && expandable_item.collapsible; + } + + if (renderer == primary_expander_cell) + renderer.visible = !data_model.is_iter_at_root_level (iter); + else if (renderer == secondary_expander_cell) + renderer.visible = data_model.is_category (item, iter); + else + assert_not_reached (); + } + } + + + + /** + * Emitted when the source list selection changes. + * + * @param item Selected item; //null// if nothing is selected. + * @since 0.2 + */ + public virtual signal void item_selected (Item? item) { } + + /** + * A {@link Code.Widgets.SourceList.VisibleFunc} should return true if the item should be + * visible; false otherwise. If //item//'s {@link Code.Widgets.SourceList.Item.visible} + * property is set to //false//, then it won't be displayed even if this method returns //true//. + * + * It is important to note that the method ''must not modify any property of //item//''. + * Doing so would result in an infinite loop, freezing the application's user interface. + * This happens because the source list invokes this method to "filter" an item after + * any of its properties changes, so by modifying a property this method would be invoking + * itself again. + * + * For most use cases, modifying the {@link Code.Widgets.SourceList.Item.visible} property is enough. + * + * The advantage of using this method is that its nature is non-destructive, and the + * changes it makes can be easily reverted (see {@link Code.Widgets.SourceList.refilter}). + * + * @param item Item to be checked. + * @return Whether //item// should be visible or not. + * @since 0.2 + */ + public delegate bool VisibleFunc (Item item); + + /** + * Root-level expandable item. + * + * This item contains the first-level source list items. It //only serves as an item container//. + * It is used to add and remove items to/from the widget. + * + * Internally, it allows the source list to connect to its {@link Code.Widgets.SourceList.ExpandableItem.child_added} + * and {@link Code.Widgets.SourceList.ExpandableItem.child_removed} signals in order to monitor + * new children additions/removals. + * + * @since 0.2 + */ + public ExpandableItem root { + get { return data_model.root; } + set { data_model.root = value; } + } + + /** + * The current selected item. + * + * Setting it to //null// un-selects the previously selected item, if there was any. + * {@link Code.Widgets.SourceList.ExpandableItem.expand_with_parents} is called on the + * item's parent to make sure it's possible to select it. + * + * @since 0.2 + */ + public Item? selected { + get { return tree.selected_item; } + set { + if (value != null && value.parent != null) + value.parent.expand_with_parents (); + tree.selected_item = value; + } + } + + /** + * Text ellipsize mode. + * + * @since 0.2 + */ + public Pango.EllipsizeMode ellipsize_mode { + get { return tree.ellipsize_mode; } + set { tree.ellipsize_mode = value; } + } + + /** + * Whether an item is being edited. + * + * @see Code.Widgets.SourceList.start_editing_item + * @since 0.2 + */ + public bool editing { + get { return tree.editing; } + } + + private Tree tree; + private DataModel data_model = new DataModel (); + + /** + * Creates a new {@link Code.Widgets.SourceList}. + * + * @return A new {@link Code.Widgets.SourceList}. + * @since 0.2 + */ + public SourceList (ExpandableItem root = new ExpandableItem ()) { + this.root = root; + } + + construct { + tree = new Tree (data_model); + + set_policy (Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); + add (tree); + show_all (); + + tree.item_selected.connect ((item) => item_selected (item)); + } + + /** + * Checks whether //item// is part of the source list. + * + * @param item The item to query. + * @return //true// if the item belongs to the source list; //false// otherwise. + * @since 0.2 + */ + public bool has_item (Item item) { + return data_model.has_item (item); + } + + /** + * Sets the method used for filtering out items. + * + * @param visible_func The method to use for filtering items. + * @param refilter Whether to call {@link Code.Widgets.SourceList.refilter} using the new function. + * @see Code.Widgets.SourceList.VisibleFunc + * @see Code.Widgets.SourceList.refilter + * @since 0.2 + */ + public void set_filter_func (VisibleFunc? visible_func, bool refilter) { + data_model.set_filter_func (visible_func); + if (refilter) + this.refilter (); + } + + /** + * Applies the filter method set by {@link Code.Widgets.SourceList.set_filter_func} + * to all the items that are part of the current tree. + * + * @see Code.Widgets.SourceList.VisibleFunc + * @see Code.Widgets.SourceList.set_filter_func + * @since 0.2 + */ + public void refilter () { + data_model.refilter (); + } + + /** + * Queries the actual expansion state of //item//. + * + * @see Code.Widgets.SourceList.ExpandableItem.expanded + * @return Whether //item// is expanded or not. + * @since 0.2 + */ + public bool is_item_expanded (Item item) requires (has_item (item)) { + var path = data_model.get_item_path (item); + return path != null && tree.is_row_expanded (path); + } + + /** + * If //item// is editable, this activates the editor; otherwise, it does nothing. + * If an item was already being edited, this will fail. + * + * @param item Item to edit. + * @see Code.Widgets.SourceList.Item.editable + * @see Code.Widgets.SourceList.editing + * @see Code.Widgets.SourceList.stop_editing + * @return true if the editing started successfully; false otherwise. + * @since 0.2 + */ + public bool start_editing_item (Item item) requires (has_item (item)) { + return tree.start_editing_item (item); + } + + /** + * Cancels any editing operation going on. + * + * @see Code.Widgets.SourceList.editing + * @see Code.Widgets.SourceList.start_editing_item + * @since 0.2 + */ + public void stop_editing () { + if (editing) + tree.stop_editing (); + } + + /** + * Turns Source List into a //drag source//. + * + * This enables items that implement {@link Code.Widgets.SourceListDragSource} + * to be dragged outside the Source List and drop data into external widgets. + * + * @param src_entries an array of {@link Gtk.TargetEntry}s indicating the targets + * that the drag will support. + * @see Code.Widgets.SourceListDragSource + * @see Code.Widgets.SourceList.disable_drag_source + * @since 0.3 + */ + public void enable_drag_source (Gtk.TargetEntry[] src_entries) { + tree.configure_drag_source (src_entries); + } + + /** + * Undoes the effect of {@link Code.Widgets.SourceList.enable_drag_source} + * + * @see Code.Widgets.SourceList.enable_drag_source + * @since 0.3 + */ + public void disable_drag_source () { + tree.configure_drag_source (null); + } + + /** + * Turns Source List into a //drop destination//. + * + * This enables items that implement {@link Code.Widgets.SourceListDragDest} + * to receive data from external widgets via drag-and-drop. + * + * @param dest_entries an array of {@link Gtk.TargetEntry}s indicating the drop + * types that Source List items will accept. + * @param actions a bitmask of possible actions for a drop onto Source List items. + * @see Code.Widgets.SourceListDragDest + * @see Code.Widgets.SourceList.disable_drag_dest + * @since 0.3 + */ + public void enable_drag_dest (Gtk.TargetEntry[] dest_entries, Gdk.DragAction actions) { + tree.configure_drag_dest (dest_entries, actions); + } + + /** + * Undoes the effect of {@link Code.Widgets.SourceList.enable_drag_dest} + * + * @see Code.Widgets.SourceList.enable_drag_dest + * @since 0.3 + */ + public void disable_drag_dest () { + tree.configure_drag_dest (null, 0); + } + + /** + * Scrolls the source list tree to make //item// visible. + * + * {@link Code.Widgets.SourceList.ExpandableItem.expand_with_parents} is called + * for the item's parent if //expand_parents// is //true//, to make sure it's not + * hidden behind a collapsed row. + * + * If use_align is //false//, then the row_align argument is ignored, and the tree + * does the minimum amount of work to scroll the item onto the screen. This means that + * the item will be scrolled to the edge closest to its current position. If the item + * is currently visible on the screen, nothing is done. + * + * @param item Item to scroll to. + * @param expand_parents Whether to recursively expand item's parent in case they are collapsed. + * @param use_align Whether to use the //row_align// argument. + * @param row_align The vertical alignment of //item//. 0.0 means top, 0.5 center, and 1.0 bottom. + * @return //true// if successful; //false// otherwise. + * @since 0.2 + */ + public bool scroll_to_item ( + Item item, + bool expand_parents = true, + bool use_align = false, + float row_align = 0 + ) requires (has_item (item)) { + if (expand_parents && item.parent != null) + item.parent.expand_with_parents (); + + return tree.scroll_to_item (item, use_align, row_align); + } + + /** + * Gets the previous item with respect to //reference//. + * + * @param reference Item to use as reference. + * @return The item that appears before //reference//, or //null// if there's none. + * @since 0.2 + */ + public Item? get_previous_item (Item reference) requires (has_item (reference)) { + // this will return null for root, so iter_n_children() will always work fine + var iter = data_model.get_item_iter (reference); + if (iter != null) { + Gtk.TreeIter new_iter = iter; // workaround for valac 0.18 + if (data_model.iter_previous (ref new_iter)) + return data_model.get_item (new_iter); + } + + return null; + } + + /** + * Gets the next item with respect to //reference//. + * + * @param reference Item to use as reference. + * @return The item that appears after //reference//, or //null// if there's none. + * @since 0.2 + */ + public Item? get_next_item (Item reference) requires (has_item (reference)) { + // this will return null for root, so iter_n_children() will always work fine + var iter = data_model.get_item_iter (reference); + if (iter != null) { + Gtk.TreeIter new_iter = iter; // workaround for valac 0.18 + if (data_model.iter_next (ref new_iter)) + return data_model.get_item (new_iter); + } + + return null; + } + + /** + * Gets the first visible child of an expandable item. + * + * @param parent Parent of the child to look up. + * @return The first visible child of //parent//, or null if it was not found. + * @since 0.2 + */ + public Item? get_first_child (ExpandableItem parent) { + return get_nth_child (parent, 0); + } + + /** + * Gets the last visible child of an expandable item. + * + * @param parent Parent of the child to look up. + * @return The last visible child of //parent//, or null if it was not found. + * @since 0.2 + */ + public Item? get_last_child (ExpandableItem parent) { + return get_nth_child (parent, (int) get_n_visible_children (parent) - 1); + } + + /** + * Gets the number of visible children of an expandable item. + * + * @param parent Item to query. + * @return Number of visible children of //parent//. + * @since 0.2 + */ + public uint get_n_visible_children (ExpandableItem parent) { + // this will return null for root, so iter_n_children() will always work properly. + var parent_iter = data_model.get_item_iter (parent); + return data_model.iter_n_children (parent_iter); + } + + private Item? get_nth_child (ExpandableItem parent, int index) { + if (index < 0) + return null; + + // this will return null for root, so iter_nth_child() will always work properly. + var parent_iter = data_model.get_item_iter (parent); + + Gtk.TreeIter child_iter; + if (data_model.iter_nth_child (out child_iter, parent_iter, index)) + return data_model.get_item (child_iter); + + return null; + } +} +} diff --git a/src/meson.build b/src/meson.build index bdafbfe288..c0d484873e 100644 --- a/src/meson.build +++ b/src/meson.build @@ -48,6 +48,9 @@ code_files = files( 'Widgets/Sidebar.vala', 'Widgets/PaneSwitcher.vala', 'Widgets/SearchBar.vala', + 'Widgets/SourceList/CellRendererBadge.vala', + 'Widgets/SourceList/CellRendererExpander.vala', + 'Widgets/SourceList/SourceList.vala', 'Widgets/SourceView.vala', 'Widgets/Terminal.vala', 'Widgets/WelcomeView.vala',