From a559fb833ce86d5bd29eead2dae237da9371e452 Mon Sep 17 00:00:00 2001 From: Pratik Borhade Date: Fri, 4 Jul 2025 15:45:18 +0200 Subject: [PATCH] Tree View: Multi-select support Add support to select multiple tree view elements (similar to outliner/anim channels) `Ctrl + LMB` to select+activate element under the mouse `Shift + LMB` to select all items between active and clicked item. As of now, only Shape key has support for multi-select. (straightforward to include other views). `KEYBLOCK_SEL` flag is used for storing selection state. Pull Request: https://projects.blender.org/blender/blender/pulls/138979 --- .../keyconfig/keymap_data/blender_default.py | 4 ++ .../editors/include/UI_abstract_view.hh | 7 +++ .../editors/interface/interface_ops.cc | 50 +++++++++++++++++-- .../editors/interface/interface_widgets.cc | 12 ++++- .../editors/interface/views/abstract_view.cc | 10 ++++ .../interface/views/abstract_view_item.cc | 22 ++++++++ .../interface_template_shape_key_tree.cc | 12 +++++ 7 files changed, 111 insertions(+), 6 deletions(-) diff --git a/scripts/presets/keyconfig/keymap_data/blender_default.py b/scripts/presets/keyconfig/keymap_data/blender_default.py index f09e4302c40..d47172ae3b4 100644 --- a/scripts/presets/keyconfig/keymap_data/blender_default.py +++ b/scripts/presets/keyconfig/keymap_data/blender_default.py @@ -1043,6 +1043,10 @@ def km_user_interface(_params): ("ui.view_scroll", {"type": 'WHEELDOWNMOUSE', "value": 'ANY'}, None), ("ui.view_scroll", {"type": 'TRACKPADPAN', "value": 'ANY'}, None), ("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS'}, None), + ("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "ctrl": True}, + {"properties": [("extend", True)]}), + ("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "shift": True}, + {"properties": [("range_select", True)]}), ]) return keymap diff --git a/source/blender/editors/include/UI_abstract_view.hh b/source/blender/editors/include/UI_abstract_view.hh index 517ff0ddae4..18413542b3f 100644 --- a/source/blender/editors/include/UI_abstract_view.hh +++ b/source/blender/editors/include/UI_abstract_view.hh @@ -76,6 +76,7 @@ class AbstractView { std::string context_menu_title; /** See #set_popup_keep_open(). */ bool popup_keep_open_ = false; + bool is_multiselect_supported_ = false; public: virtual ~AbstractView() = default; @@ -152,6 +153,8 @@ class AbstractView { void set_popup_keep_open(); void clear_search_highlight(); + void allow_multiselect_items(); + bool is_multiselect_supported() const; protected: AbstractView() = default; @@ -199,6 +202,7 @@ class AbstractViewItem { bool is_activatable_ = true; bool is_interactive_ = true; bool is_active_ = false; + bool is_selected_ = false; bool is_renaming_ = false; /** See #is_search_highlight(). */ bool is_highlighted_search_ = false; @@ -232,6 +236,8 @@ class AbstractViewItem { */ virtual std::optional should_be_active() const; + virtual std::optional should_be_selected() const; + virtual void set_selected(const bool select); /** * Queries if the view item supports renaming in principle. Renaming may still fail, e.g. if * another item is already being renamed. @@ -306,6 +312,7 @@ class AbstractViewItem { * can't be sure about the item state. */ bool is_active() const; + bool is_selected() const; /** * Should this item be highlighted as matching search result? Only one item should be highlighted * this way at a time. Pressing enter will activate it. diff --git a/source/blender/editors/interface/interface_ops.cc b/source/blender/editors/interface/interface_ops.cc index 056f915a24b..5197b9b1b7f 100644 --- a/source/blender/editors/interface/interface_ops.cc +++ b/source/blender/editors/interface/interface_ops.cc @@ -2792,16 +2792,47 @@ static void UI_OT_view_item_rename(wmOperatorType *ot) ot->flag = OPTYPE_INTERNAL; } -static wmOperatorStatus ui_view_item_select_exec(bContext *C, wmOperator * /*op*/) +static wmOperatorStatus ui_view_item_select_invoke(bContext *C, + wmOperator *op, + const wmEvent * /*event*/) { const wmWindow &win = *CTX_wm_window(C); const ARegion ®ion = *CTX_wm_region(C); - if (AbstractViewItem *active_item = UI_region_views_find_item_at(region, win.eventstate->xy)) { - active_item->activate(*C); + AbstractViewItem *clicked_item = UI_region_views_find_item_at(region, win.eventstate->xy); + if (clicked_item == nullptr) { + return OPERATOR_CANCELLED; + } + + AbstractView &view = clicked_item->get_view(); + const bool is_multiselect = view.is_multiselect_supported(); + const bool extend = RNA_boolean_get(op->ptr, "extend") && is_multiselect; + const bool range_select = RNA_boolean_get(op->ptr, "range_select") && is_multiselect; + + if (!extend) { + /* Keep previous selection for extend selection, see: !138979. */ + view.foreach_view_item([](AbstractViewItem &item) { item.set_selected(false); }); + } + + if (range_select) { + bool is_inside_range = false; + view.foreach_view_item([&](AbstractViewItem &item) { + if ((item.is_active()) ^ (&item == clicked_item)) { + is_inside_range = !is_inside_range; + /* Select end items from the range. */ + item.set_selected(true); + } + if (is_inside_range) { + /* Select items within the range. */ + item.set_selected(true); + } + }); return OPERATOR_FINISHED; } - return OPERATOR_CANCELLED; + + clicked_item->activate(*C); + + return OPERATOR_FINISHED; } static void UI_OT_view_item_select(wmOperatorType *ot) @@ -2810,10 +2841,19 @@ static void UI_OT_view_item_select(wmOperatorType *ot) ot->idname = "UI_OT_view_item_select"; ot->description = "Activate selected view item"; - ot->exec = ui_view_item_select_exec; + ot->invoke = ui_view_item_select_invoke; ot->poll = ui_view_focused_poll; ot->flag = OPTYPE_INTERNAL; + + PropertyRNA *prop = RNA_def_boolean(ot->srna, "extend", false, "extend", "Extend Selection"); + RNA_def_property_flag(prop, PROP_SKIP_SAVE); + prop = RNA_def_boolean(ot->srna, + "range_select", + false, + "Range Select", + "Select all between clicked and active items"); + RNA_def_property_flag(prop, PROP_SKIP_SAVE); } /** \} */ diff --git a/source/blender/editors/interface/interface_widgets.cc b/source/blender/editors/interface/interface_widgets.cc index 5069e00788e..ceb62088418 100644 --- a/source/blender/editors/interface/interface_widgets.cc +++ b/source/blender/editors/interface/interface_widgets.cc @@ -44,6 +44,8 @@ #include "GPU_matrix.hh" #include "GPU_state.hh" +#include "UI_abstract_view.hh" + #ifdef WITH_INPUT_IME # include "WM_types.hh" #endif @@ -4369,9 +4371,17 @@ static void widget_list_itembut(uiBut *but, const float zoom) { rcti draw_rect = *rect; + bool is_selected = state->but_flag & UI_SELECT; if (but->type == UI_BTYPE_VIEW_ITEM) { uiButViewItem *item_but = static_cast(but); + blender::ui::AbstractViewItem &view_item = *item_but->view_item; + + if (!view_item.is_active() && view_item.is_selected()) { + copy_v4_v4_uchar(wcol->inner, wcol->inner_sel); + color_blend_v3_v3(wcol->inner, wcol->outline, 0.5); + is_selected = true; + } if (item_but->draw_width > 0) { BLI_rcti_resize_x(&draw_rect, zoom * item_but->draw_width); } @@ -4388,7 +4398,7 @@ static void widget_list_itembut(uiBut *but, if (state->but_flag & UI_HOVER) { color_blend_v3_v3(wcol->inner, wcol->text, 0.2); - wcol->inner[3] = (state->but_flag & UI_SELECT) ? 255 : 20; + wcol->inner[3] = is_selected ? 255 : 20; } widgetbase_draw(&wtb, wcol); diff --git a/source/blender/editors/interface/views/abstract_view.cc b/source/blender/editors/interface/views/abstract_view.cc index f52af67c071..71d2a1d7d73 100644 --- a/source/blender/editors/interface/views/abstract_view.cc +++ b/source/blender/editors/interface/views/abstract_view.cc @@ -246,6 +246,16 @@ void AbstractView::clear_search_highlight() { this->foreach_view_item([](AbstractViewItem &item) { item.is_highlighted_search_ = false; }); } + +void AbstractView::allow_multiselect_items() +{ + is_multiselect_supported_ = true; +} + +bool AbstractView::is_multiselect_supported() const +{ + return is_multiselect_supported_; +} /** \} */ } // namespace blender::ui diff --git a/source/blender/editors/interface/views/abstract_view_item.cc b/source/blender/editors/interface/views/abstract_view_item.cc index a34dc69b48a..c0f44d42d8d 100644 --- a/source/blender/editors/interface/views/abstract_view_item.cc +++ b/source/blender/editors/interface/views/abstract_view_item.cc @@ -30,6 +30,7 @@ void AbstractViewItem::update_from_old(const AbstractViewItem &old) is_active_ = old.is_active_; is_renaming_ = old.is_renaming_; is_highlighted_search_ = old.is_highlighted_search_; + is_selected_ = old.is_selected_; } /** \} */ @@ -71,6 +72,7 @@ void AbstractViewItem::activate(bContext &C) { if (set_state_active()) { on_activate(C); + set_selected(true); } } @@ -79,6 +81,16 @@ void AbstractViewItem::deactivate() is_active_ = false; } +std::optional AbstractViewItem::should_be_selected() const +{ + return std::nullopt; +} + +void AbstractViewItem::set_selected(const bool select) +{ + is_selected_ = select; +} + /** \} */ /* ---------------------------------------------------------------------- */ @@ -97,6 +109,9 @@ void AbstractViewItem::change_state_delayed() is_active_ = false; } } + if (std::optional is_selected = should_be_selected()) { + set_selected(is_selected.value_or(false)); + } } /** \} */ @@ -320,6 +335,13 @@ bool AbstractViewItem::is_active() const return is_active_; } +bool AbstractViewItem::is_selected() const +{ + BLI_assert_msg(this->get_view().is_reconstructed(), + "State can't be queried until reconstruction is completed"); + return is_selected_; +} + bool AbstractViewItem::is_search_highlight() const { return is_highlighted_search_; diff --git a/source/blender/editors/object/interface_template_shape_key_tree.cc b/source/blender/editors/object/interface_template_shape_key_tree.cc index a4928d7f72d..f69365c999c 100644 --- a/source/blender/editors/object/interface_template_shape_key_tree.cc +++ b/source/blender/editors/object/interface_template_shape_key_tree.cc @@ -197,6 +197,17 @@ class ShapeKeyItem : public ui::AbstractTreeViewItem { ED_undo_push(&C, "Set Active Shape Key"); } + std::optional should_be_selected() const override + { + return shape_key_.kb->flag & KEYBLOCK_SEL; + } + + void set_selected(const bool select) override + { + AbstractViewItem::set_selected(select); + SET_FLAG_FROM_TEST(shape_key_.kb->flag, select, KEYBLOCK_SEL); + } + bool supports_renaming() const override { return true; @@ -256,6 +267,7 @@ void template_tree(uiLayout *layout, bContext *C) std::make_unique(*ob)); tree_view->set_context_menu_title("Shape Key"); tree_view->set_default_rows(4); + tree_view->allow_multiselect_items(); ui::TreeViewBuilder::build_tree_view(*C, *tree_view, *layout); }