mirror of
				https://github.com/RGBCube/serenity
				synced 2025-10-31 15:12:45 +00:00 
			
		
		
		
	LibGUI: Implement searching/jumping as you type in views
This allows the user to start typing and highlighting and jumping to a match in ColumnsView, IconView, TableView and TreeView if the model supports it.
This commit is contained in:
		
							parent
							
								
									307f0bc778
								
							
						
					
					
						commit
						52a847a0eb
					
				
					 13 changed files with 244 additions and 19 deletions
				
			
		|  | @ -25,7 +25,9 @@ | |||
|  */ | ||||
| 
 | ||||
| #include <AK/StringBuilder.h> | ||||
| #include <AK/Utf8View.h> | ||||
| #include <AK/Vector.h> | ||||
| #include <LibCore/Timer.h> | ||||
| #include <LibGUI/AbstractView.h> | ||||
| #include <LibGUI/DragOperation.h> | ||||
| #include <LibGUI/Model.h> | ||||
|  | @ -33,6 +35,7 @@ | |||
| #include <LibGUI/Painter.h> | ||||
| #include <LibGUI/ScrollBar.h> | ||||
| #include <LibGUI/TextBox.h> | ||||
| #include <LibGfx/Palette.h> | ||||
| 
 | ||||
| namespace GUI { | ||||
| 
 | ||||
|  | @ -44,6 +47,8 @@ AbstractView::AbstractView() | |||
| 
 | ||||
| AbstractView::~AbstractView() | ||||
| { | ||||
|     if (m_searching_timer) | ||||
|         m_searching_timer->stop(); | ||||
|     if (m_model) | ||||
|         m_model->unregister_view({}, *this); | ||||
| } | ||||
|  | @ -421,9 +426,13 @@ void AbstractView::set_cursor(ModelIndex index, SelectionUpdate selection_update | |||
| { | ||||
|     if (!model() || !index.is_valid()) { | ||||
|         m_cursor_index = {}; | ||||
|         cancel_searching(); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     if (!m_cursor_index.is_valid() || model()->parent_index(m_cursor_index) != model()->parent_index(index)) | ||||
|         cancel_searching(); | ||||
| 
 | ||||
|     if (model()->is_valid(index)) { | ||||
|         if (selection_update == SelectionUpdate::Set) | ||||
|             set_selection(index); | ||||
|  | @ -517,7 +526,152 @@ void AbstractView::keydown_event(KeyEvent& event) | |||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     if (is_searchable()) { | ||||
|         if (event.key() == KeyCode::Key_Backspace) { | ||||
|             if (is_searching()) { | ||||
|                 //if (event.modifiers() == Mod_Ctrl) {
 | ||||
|                 // TODO: delete last word
 | ||||
|                 //}
 | ||||
|                 Utf8View view(m_searching); | ||||
|                 size_t n_code_points = view.length(); | ||||
|                 if (n_code_points > 1) { | ||||
|                     n_code_points--; | ||||
|                     StringBuilder sb; | ||||
|                     for (auto it = view.begin(); it != view.end(); ++it) { | ||||
|                         if (n_code_points == 0) | ||||
|                             break; | ||||
|                         n_code_points--; | ||||
|                         sb.append_code_point(*it); | ||||
|                     } | ||||
|                     do_search(sb.to_string()); | ||||
|                     start_searching_timer(); | ||||
|                 } else { | ||||
|                     cancel_searching(); | ||||
|                 } | ||||
| 
 | ||||
|                 event.accept(); | ||||
|                 return; | ||||
|             } | ||||
|         } else if (event.key() == KeyCode::Key_Escape) { | ||||
|             if (is_searching()) { | ||||
|                 cancel_searching(); | ||||
| 
 | ||||
|                 event.accept(); | ||||
|                 return; | ||||
|             } | ||||
|         } else if (!event.ctrl() && !event.alt() && event.code_point() != 0) { | ||||
|             StringBuilder sb; | ||||
|             sb.append(m_searching); | ||||
|             sb.append_code_point(event.code_point()); | ||||
|             do_search(sb.to_string()); | ||||
|             start_searching_timer(); | ||||
| 
 | ||||
|             event.accept(); | ||||
|             return; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Widget::keydown_event(event); | ||||
| } | ||||
| 
 | ||||
| void AbstractView::cancel_searching() | ||||
| { | ||||
|     m_searching = nullptr; | ||||
|     if (m_searching_timer) | ||||
|         m_searching_timer->stop(); | ||||
|     if (m_highlighted_search_index.is_valid()) { | ||||
|         m_highlighted_search_index = {}; | ||||
|         update(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| void AbstractView::start_searching_timer() | ||||
| { | ||||
|     if (!m_searching_timer) { | ||||
|         m_searching_timer = add<Core::Timer>(); | ||||
|         m_searching_timer->set_single_shot(true); | ||||
|         m_searching_timer->on_timeout = [this] { | ||||
|             cancel_searching(); | ||||
|         }; | ||||
|     } | ||||
|     m_searching_timer->set_interval(5 * 1000); | ||||
|     m_searching_timer->restart(); | ||||
| } | ||||
| 
 | ||||
| void AbstractView::do_search(String&& searching) | ||||
| { | ||||
|     if (searching.is_empty() || !model()) { | ||||
|         cancel_searching(); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     auto found_indexes = model()->matches(searching, Model::MatchesFlag::FirstMatchOnly | Model::MatchesFlag::MatchAtStart | Model::MatchesFlag::CaseInsensitive, model()->parent_index(cursor_index())); | ||||
|     if (!found_indexes.is_empty() && found_indexes[0].is_valid()) { | ||||
|         auto& index = found_indexes[0]; | ||||
|         m_highlighted_search_index = index; | ||||
|         m_searching = move(searching); | ||||
|         set_selection(index); | ||||
|         scroll_into_view(index); | ||||
|         update(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| bool AbstractView::is_searchable() const | ||||
| { | ||||
|     if (!m_searchable || !model()) | ||||
|         return false; | ||||
|     return model()->is_searchable(); | ||||
| } | ||||
| 
 | ||||
| void AbstractView::set_searchable(bool searchable) | ||||
| { | ||||
|     if (m_searchable == searchable) | ||||
|         return; | ||||
|     m_searchable = searchable; | ||||
|     if (!m_searchable) | ||||
|         cancel_searching(); | ||||
| } | ||||
| 
 | ||||
| bool AbstractView::is_highlighting_searching(const ModelIndex& index) const | ||||
| { | ||||
|     return index == m_highlighted_search_index; | ||||
| } | ||||
| 
 | ||||
| void AbstractView::draw_item_text(Gfx::Painter& painter, const ModelIndex& index, bool is_selected, const Gfx::IntRect& text_rect, const StringView& item_text, const Gfx::Font& font, Gfx::TextAlignment alignment, Gfx::TextElision elision) | ||||
| { | ||||
|     Color text_color; | ||||
|     if (is_selected) | ||||
|         text_color = is_focused() ? palette().selection_text() : palette().inactive_selection_text(); | ||||
|     else | ||||
|         text_color = index.data(ModelRole::ForegroundColor).to_color(palette().color(foreground_role())); | ||||
|     if (is_highlighting_searching(index)) { | ||||
|         Utf8View searching_text(searching()); | ||||
|         auto searching_length = searching_text.length(); | ||||
| 
 | ||||
|         // Highlight the text background first
 | ||||
|         painter.draw_text([&](const Gfx::IntRect& rect, u32) { | ||||
|             if (searching_length > 0) { | ||||
|                 searching_length--; | ||||
|                 painter.fill_rect(rect.inflated(0, 2), palette().highlight_searching()); | ||||
|             } | ||||
|         }, | ||||
|             text_rect, item_text, font, alignment, elision); | ||||
| 
 | ||||
|         // Then draw the text
 | ||||
|         auto highlight_text_color = palette().highlight_searching_text(); | ||||
|         searching_length = searching_text.length(); | ||||
|         painter.draw_text([&](const Gfx::IntRect& rect, u32 code_point) { | ||||
|             if (searching_length > 0) { | ||||
|                 searching_length--; | ||||
|                 painter.draw_glyph_or_emoji(rect.location(), code_point, font, highlight_text_color); | ||||
|             } else { | ||||
|                 painter.draw_glyph_or_emoji(rect.location(), code_point, font, text_color); | ||||
|             } | ||||
|         }, | ||||
|             text_rect, item_text, font, alignment, elision); | ||||
|     } else { | ||||
|         painter.draw_text(text_rect, item_text, font, alignment, text_color, elision); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ | |||
| #include <AK/Function.h> | ||||
| #include <LibGUI/ModelSelection.h> | ||||
| #include <LibGUI/ScrollableWidget.h> | ||||
| #include <LibGfx/TextElision.h> | ||||
| 
 | ||||
| namespace GUI { | ||||
| 
 | ||||
|  | @ -68,6 +69,11 @@ public: | |||
|     bool is_editable() const { return m_editable; } | ||||
|     void set_editable(bool editable) { m_editable = editable; } | ||||
| 
 | ||||
|     bool is_searching() const { return !m_searching.is_null(); } | ||||
| 
 | ||||
|     bool is_searchable() const; | ||||
|     void set_searchable(bool); | ||||
| 
 | ||||
|     enum EditTrigger { | ||||
|         None = 0, | ||||
|         DoubleClicked = 1 << 0, | ||||
|  | @ -138,13 +144,22 @@ protected: | |||
|     virtual void remove_selection(const ModelIndex&); | ||||
|     virtual void toggle_selection(const ModelIndex&); | ||||
| 
 | ||||
|     void draw_item_text(Gfx::Painter&, const ModelIndex&, bool, const Gfx::IntRect&, const StringView&, const Gfx::Font&, Gfx::TextAlignment, Gfx::TextElision); | ||||
| 
 | ||||
|     virtual void did_scroll() override; | ||||
|     void set_hovered_index(const ModelIndex&); | ||||
|     void activate(const ModelIndex&); | ||||
|     void activate_selected(); | ||||
|     void update_edit_widget_position(); | ||||
| 
 | ||||
|     StringView searching() const { return m_searching; } | ||||
|     void cancel_searching(); | ||||
|     void start_searching_timer(); | ||||
|     void do_search(String&&); | ||||
|     bool is_highlighting_searching(const ModelIndex&) const; | ||||
| 
 | ||||
|     bool m_editable { false }; | ||||
|     bool m_searchable { true }; | ||||
|     ModelIndex m_edit_index; | ||||
|     RefPtr<Widget> m_edit_widget; | ||||
|     Gfx::IntRect m_edit_widget_content_rect; | ||||
|  | @ -154,6 +169,7 @@ protected: | |||
|     bool m_might_drag { false }; | ||||
| 
 | ||||
|     ModelIndex m_hovered_index; | ||||
|     ModelIndex m_highlighted_search_index; | ||||
| 
 | ||||
|     int m_key_column { -1 }; | ||||
|     SortOrder m_sort_order; | ||||
|  | @ -161,6 +177,8 @@ protected: | |||
| private: | ||||
|     RefPtr<Model> m_model; | ||||
|     ModelSelection m_selection; | ||||
|     String m_searching; | ||||
|     RefPtr<Core::Timer> m_searching_timer; | ||||
|     ModelIndex m_cursor_index; | ||||
|     unsigned m_edit_triggers { EditTrigger::DoubleClicked | EditTrigger::EditKeyPressed }; | ||||
|     bool m_activates_on_selection { false }; | ||||
|  |  | |||
|  | @ -141,8 +141,7 @@ void ColumnsView::paint_event(PaintEvent& event) | |||
|                 icon_rect.right() + 1 + icon_spacing(), row * item_height(), | ||||
|                 column.width - icon_spacing() - icon_size() - icon_spacing() - icon_spacing() - s_arrow_bitmap_width - icon_spacing(), item_height() | ||||
|             }; | ||||
|             auto text = index.data().to_string(); | ||||
|             painter.draw_text(text_rect, text, Gfx::TextAlignment::CenterLeft, text_color); | ||||
|             draw_item_text(painter, index, is_selected_row, text_rect, index.data().to_string(), font_for_index(index), Gfx::TextAlignment::CenterLeft, Gfx::TextElision::None); | ||||
| 
 | ||||
|             bool expandable = model()->row_count(index) > 0; | ||||
|             if (expandable) { | ||||
|  |  | |||
|  | @ -627,4 +627,21 @@ void FileSystemModel::set_data(const ModelIndex& index, const Variant& data) | |||
|     } | ||||
| } | ||||
| 
 | ||||
| Vector<ModelIndex, 1> FileSystemModel::matches(const StringView& searching, unsigned flags, const ModelIndex& index) | ||||
| { | ||||
|     Node& node = const_cast<Node&>(this->node(index)); | ||||
|     node.reify_if_needed(); | ||||
|     Vector<ModelIndex, 1> found_indexes; | ||||
|     for (auto& child : node.children) { | ||||
|         if (string_matches(child.name, searching, flags)) { | ||||
|             const_cast<Node&>(child).reify_if_needed(); | ||||
|             found_indexes.append(child.index(Column::Name)); | ||||
|             if (flags & FirstMatchOnly) | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     return found_indexes; | ||||
| } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -150,7 +150,9 @@ public: | |||
|     virtual bool accepts_drag(const ModelIndex&, const StringView& data_type) override; | ||||
|     virtual bool is_column_sortable(int column_index) const override { return column_index != Column::Icon; } | ||||
|     virtual bool is_editable(const ModelIndex&) const override; | ||||
|     virtual bool is_searchable() const override { return true; } | ||||
|     virtual void set_data(const ModelIndex&, const Variant&) override; | ||||
|     virtual Vector<ModelIndex, 1> matches(const StringView&, unsigned = MatchesFlag::AllMatching, const ModelIndex& = ModelIndex()) override; | ||||
| 
 | ||||
|     static String timestamp_string(time_t timestamp) | ||||
|     { | ||||
|  |  | |||
|  | @ -119,4 +119,17 @@ ModelIndex FilteringProxyModel::map(const ModelIndex& index) const | |||
|     return {}; | ||||
| } | ||||
| 
 | ||||
| bool FilteringProxyModel::is_searchable() const | ||||
| { | ||||
|     return m_model.is_searchable(); | ||||
| } | ||||
| 
 | ||||
| Vector<ModelIndex, 1> FilteringProxyModel::matches(const StringView& searching, unsigned flags, const ModelIndex& index) | ||||
| { | ||||
|     auto found_indexes = m_model.matches(searching, flags, index); | ||||
|     for (size_t i = 0; i < found_indexes.size(); i++) | ||||
|         found_indexes[i] = map(found_indexes[i]); | ||||
|     return found_indexes; | ||||
| } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -48,6 +48,8 @@ public: | |||
|     virtual Variant data(const ModelIndex&, ModelRole = ModelRole::Display) const override; | ||||
|     virtual void update() override; | ||||
|     virtual ModelIndex index(int row, int column = 0, const ModelIndex& parent = ModelIndex()) const override; | ||||
|     virtual bool is_searchable() const override; | ||||
|     virtual Vector<ModelIndex, 1> matches(const StringView&, unsigned = MatchesFlag::AllMatching, const ModelIndex& = ModelIndex()) override; | ||||
| 
 | ||||
|     void set_filter_term(const StringView& term); | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ | |||
|  */ | ||||
| 
 | ||||
| #include <AK/StringBuilder.h> | ||||
| #include <AK/Utf8View.h> | ||||
| #include <LibCore/Timer.h> | ||||
| #include <LibGUI/DragOperation.h> | ||||
| #include <LibGUI/IconView.h> | ||||
|  | @ -464,13 +465,8 @@ void IconView::paint_event(PaintEvent& event) | |||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Color text_color; | ||||
|         if (item_data.selected) | ||||
|             text_color = is_focused() ? palette().selection_text() : palette().inactive_selection_text(); | ||||
|         else | ||||
|             text_color = item_data.index.data(ModelRole::ForegroundColor).to_color(palette().color(foreground_role())); | ||||
|         painter.fill_rect(item_data.text_rect, background_color); | ||||
|         painter.draw_text(item_data.text_rect, item_text.to_string(), font_for_index(item_data.index), Gfx::TextAlignment::Center, text_color, Gfx::TextElision::Right); | ||||
|         draw_item_text(painter, item_data.index, item_data.selected, item_data.text_rect, item_text.to_string(), font_for_index(item_data.index), Gfx::TextAlignment::Center, Gfx::TextElision::Right); | ||||
| 
 | ||||
|         if (item_data.index == m_drop_candidate_index) { | ||||
|             // FIXME: This visualization is not great, as it's also possible to drop things on the text label..
 | ||||
|  |  | |||
|  | @ -59,6 +59,13 @@ public: | |||
|         InvalidateAllIndexes = 1 << 0, | ||||
|     }; | ||||
| 
 | ||||
|     enum MatchesFlag { | ||||
|         AllMatching = 0, | ||||
|         FirstMatchOnly = 1 << 0, | ||||
|         CaseInsensitive = 1 << 1, | ||||
|         MatchAtStart = 1 << 2, | ||||
|     }; | ||||
| 
 | ||||
|     virtual ~Model(); | ||||
| 
 | ||||
|     virtual int row_count(const ModelIndex& = ModelIndex()) const = 0; | ||||
|  | @ -70,9 +77,11 @@ public: | |||
|     virtual ModelIndex parent_index(const ModelIndex&) const { return {}; } | ||||
|     virtual ModelIndex index(int row, int column = 0, const ModelIndex& parent = ModelIndex()) const; | ||||
|     virtual bool is_editable(const ModelIndex&) const { return false; } | ||||
|     virtual bool is_searchable() const { return false; } | ||||
|     virtual void set_data(const ModelIndex&, const Variant&) { } | ||||
|     virtual int tree_column() const { return 0; } | ||||
|     virtual bool accepts_drag(const ModelIndex&, const StringView& data_type); | ||||
|     virtual Vector<ModelIndex, 1> matches(const StringView&, unsigned = MatchesFlag::AllMatching, const ModelIndex& = ModelIndex()) { return {}; } | ||||
| 
 | ||||
|     virtual bool is_column_sortable([[maybe_unused]] int column_index) const { return true; } | ||||
|     virtual void sort([[maybe_unused]] int column, SortOrder) { } | ||||
|  | @ -97,6 +106,14 @@ protected: | |||
|     void for_each_view(Function<void(AbstractView&)>); | ||||
|     void did_update(unsigned flags = UpdateFlag::InvalidateAllIndexes); | ||||
| 
 | ||||
|     static bool string_matches(const StringView& str, const StringView& needle, unsigned flags) | ||||
|     { | ||||
|         auto case_sensitivity = (flags & CaseInsensitive) ? CaseSensitivity::CaseInsensitive : CaseSensitivity::CaseSensitive; | ||||
|         if (flags & MatchAtStart) | ||||
|             return str.starts_with(needle, case_sensitivity); | ||||
|         return str.contains(needle, case_sensitivity); | ||||
|     } | ||||
| 
 | ||||
|     ModelIndex create_index(int row, int column, const void* data = nullptr) const; | ||||
| 
 | ||||
| private: | ||||
|  |  | |||
|  | @ -295,4 +295,17 @@ void SortingProxyModel::set_data(const ModelIndex& proxy_index, const Variant& d | |||
|     source().set_data(map_to_source(proxy_index), data); | ||||
| } | ||||
| 
 | ||||
| bool SortingProxyModel::is_searchable() const | ||||
| { | ||||
|     return source().is_searchable(); | ||||
| } | ||||
| 
 | ||||
| Vector<ModelIndex, 1> SortingProxyModel::matches(const StringView& searching, unsigned flags, const ModelIndex& proxy_index) | ||||
| { | ||||
|     auto found_indexes = source().matches(searching, flags, map_to_source(proxy_index)); | ||||
|     for (size_t i = 0; i < found_indexes.size(); i++) | ||||
|         found_indexes[i] = map_to_proxy(found_indexes[i]); | ||||
|     return found_indexes; | ||||
| } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -46,7 +46,9 @@ public: | |||
|     virtual ModelIndex parent_index(const ModelIndex&) const override; | ||||
|     virtual ModelIndex index(int row, int column, const ModelIndex& parent) const override; | ||||
|     virtual bool is_editable(const ModelIndex&) const override; | ||||
|     virtual bool is_searchable() const override; | ||||
|     virtual void set_data(const ModelIndex&, const Variant&) override; | ||||
|     virtual Vector<ModelIndex, 1> matches(const StringView&, unsigned = MatchesFlag::AllMatching, const ModelIndex& = ModelIndex()) override; | ||||
| 
 | ||||
|     virtual bool is_column_sortable(int column_index) const override; | ||||
| 
 | ||||
|  |  | |||
|  | @ -127,11 +127,6 @@ void TableView::paint_event(PaintEvent& event) | |||
|                             painter.blit(cell_rect.location(), *bitmap, bitmap->rect()); | ||||
|                     } | ||||
|                 } else { | ||||
|                     Color text_color; | ||||
|                     if (is_selected_row) | ||||
|                         text_color = is_focused() ? palette().selection_text() : palette().inactive_selection_text(); | ||||
|                     else | ||||
|                         text_color = cell_index.data(ModelRole::ForegroundColor).to_color(palette().color(foreground_role())); | ||||
|                     if (!is_selected_row) { | ||||
|                         auto cell_background_color = cell_index.data(ModelRole::BackgroundColor); | ||||
|                         if (cell_background_color.is_valid()) | ||||
|  | @ -139,7 +134,7 @@ void TableView::paint_event(PaintEvent& event) | |||
|                     } | ||||
| 
 | ||||
|                     auto text_alignment = cell_index.data(ModelRole::TextAlignment).to_text_alignment(Gfx::TextAlignment::CenterLeft); | ||||
|                     painter.draw_text(cell_rect, data.to_string(), font_for_index(cell_index), text_alignment, text_color, Gfx::TextElision::Right); | ||||
|                     draw_item_text(painter, cell_index, is_selected_row, cell_rect, data.to_string(), font_for_index(cell_index), text_alignment, Gfx::TextElision::Right); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|  |  | |||
|  | @ -318,10 +318,8 @@ void TreeView::paint_event(PaintEvent& event) | |||
|                         if (auto bitmap = data.as_icon().bitmap_for_size(16)) | ||||
|                             painter.blit(cell_rect.location(), *bitmap, bitmap->rect()); | ||||
|                     } else { | ||||
|                         if (!is_selected_row) | ||||
|                             text_color = cell_index.data(ModelRole::ForegroundColor).to_color(palette().color(foreground_role())); | ||||
|                         auto text_alignment = cell_index.data(ModelRole::TextAlignment).to_text_alignment(Gfx::TextAlignment::CenterLeft); | ||||
|                         painter.draw_text(cell_rect, data.to_string(), font_for_index(cell_index), text_alignment, text_color, Gfx::TextElision::Right); | ||||
|                         draw_item_text(painter, cell_index, is_selected_row, cell_rect, data.to_string(), font_for_index(cell_index), text_alignment, Gfx::TextElision::Right); | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|  | @ -340,8 +338,7 @@ void TreeView::paint_event(PaintEvent& event) | |||
|                     icon_rect.right() + 1 + icon_spacing(), rect.y(), | ||||
|                     rect.width() - icon_size() - icon_spacing(), rect.height() | ||||
|                 }; | ||||
|                 auto node_text = index.data().to_string(); | ||||
|                 painter.draw_text(text_rect, node_text, font_for_index(index), Gfx::TextAlignment::Center, text_color); | ||||
|                 draw_item_text(painter, index, is_selected_row, text_rect, index.data().to_string(), font_for_index(index), Gfx::TextAlignment::Center, Gfx::TextElision::None); | ||||
|                 auto index_at_indent = index; | ||||
|                 for (int i = indent_level; i > 0; --i) { | ||||
|                     auto parent_of_index_at_indent = index_at_indent.parent(); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Tom
						Tom