""" Components/DataTables ===================== .. seealso:: `Material Design spec, DataTables `_ .. rubric:: Data tables display sets of data across rows and columns. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-previous.png :align: center .. warning:: Data tables are still far from perfect. Errors are possible and we hope you inform us about them. """ # Special thanks for the info - # https://stackoverflow.com/questions/50219281/python-how-to-add-vertical-scroll-in-recycleview __all__ = ("MDDataTable",) from kivy import Logger from kivy.clock import Clock from kivy.lang import Builder from kivy.metrics import dp from kivy.properties import ( BooleanProperty, DictProperty, ListProperty, NumericProperty, ObjectProperty, OptionProperty, StringProperty, ) from kivy.uix.behaviors import ButtonBehavior, FocusBehavior from kivy.uix.boxlayout import BoxLayout from kivy.uix.recyclegridlayout import RecycleGridLayout from kivy.uix.recycleview import RecycleView from kivy.uix.recycleview.layout import LayoutSelectionBehavior from kivy.uix.recycleview.views import RecycleDataViewBehavior from kivy.uix.scrollview import ScrollView from kivymd.theming import ThemableBehavior from kivymd.uix.behaviors import HoverBehavior from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.dialog import BaseDialog from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.tooltip import MDTooltip Builder.load_string( """ #:import DEVICE_TYPE kivymd.material_resources.DEVICE_TYPE #:import StiffScrollEffect kivymd.stiffscroll.StiffScrollEffect orientation: "vertical" canvas.before: Color: rgba: (root.theme_cls.bg_darkest if root.theme_cls.theme_style == "Light" else root.theme_cls.bg_light) \ if self.selected else root.theme_cls.bg_normal Rectangle: pos: self.pos size: self.size on_press: if DEVICE_TYPE != "desktop": root.table.on_mouse_select(self) on_enter: if DEVICE_TYPE == "desktop": root.table.on_mouse_select(self) MDBoxLayout: id: box padding: "8dp", "8dp", 0, "8dp" spacing: "16dp" MDCheckbox: id: check size_hint: None, None size: 0, 0 opacity: 0 on_active: root.select_check(self.active) MDLabel: id: label text: " " + root.text color: (1, 1, 1, 1) if root.theme_cls.theme_style == "Dark" else (0, 0, 0, 1) MDSeparator: orientation: "vertical" size_hint_y: None height: self.minimum_height spacing: "4dp" tooltip_text: root.text MDLabel: text: " " + root.text size_hint_y: None height: self.texture_size[1] bold: True color: (1, 1, 1, 1) if root.theme_cls.theme_style == "Dark" else (0, 0, 0, 1) MDSeparator: id: separator bar_width: 0 do_scroll: False size_hint: 1, None height: header.height MDGridLayout: id: header rows: 1 cols_minimum: root.cols_minimum adaptive_size: True padding: 0, "8dp", 0, 0 MDBoxLayout: orientation: "vertical" MDBoxLayout: id: box padding: "8dp", "8dp", "4dp", 0 spacing: "16dp" MDCheckbox: id: check size_hint: None, None size: 0, 0 opacity: 0 on_active: root.table_data.select_all(self.state) disabled: True #MDIconButton: # id: sort_button # icon: "menu-up" # pos_hint: {"center_y": 1} # ripple_scale: .65 # on_release: root.table_data.sort_by_name() CellHeader: id: first_cell MDSeparator: data: root.recycle_data data_first_cells: root.data_first_cells key_viewclass: "viewclass" effect_cls: StiffScrollEffect TableRecycleGridLayout: id: row_controller key_selection: "selectable" cols: root.total_col_headings cols_minimum: root.cols_minimum default_size: None, dp(52) default_size_hint: 1, None size_hint: None, None height: self.minimum_height width: self.minimum_width orientation: "vertical" multiselect: True touch_multiselect: True adaptive_height: True spacing: "8dp" Widget: MDLabel: text: "Rows per page" size_hint: None, 1 width: self.texture_size[0] text_size: None, None font_style: "Caption" color: (1, 1, 1, 1) if root.theme_cls.theme_style == "Dark" else (0, 0, 0, 1) MDDropDownItem: id: drop_item pos_hint: {'center_y': .5} text: str(root.table_data.rows_num) font_size: "14sp" on_release: root.table_data.open_pagination_menu() Widget: size_hint_x: None width: "32dp" MDLabel: id: label_rows_per_page text: f"1-{root.table_data.rows_num} of {len(root.table_data.row_data)}" size_hint: None, 1 #width: self.texture_size[0] text_size: None, None font_style: "Caption" color: (1, 1, 1, 1) if root.theme_cls.theme_style == "Dark" else (0, 0, 0, 1) MDIconButton: id: button_back icon: "chevron-left" user_font_size: "20sp" pos_hint: {'center_y': .5} disabled: True on_release: root.table_data.set_next_row_data_parts("back") MDIconButton: id: button_forward icon: "chevron-right" user_font_size: "20sp" pos_hint: {'center_y': .5} on_release: root.table_data.set_next_row_data_parts("forward") MDCard: id: container orientation: "vertical" elevation: 14 md_bg_color: 0, 0, 0, 0 padding: "24dp", "24dp", "8dp", "8dp" canvas: Color: rgba: root.theme_cls.bg_normal RoundedRectangle: pos: self.pos size: self.size """ ) class TableRecycleGridLayout( FocusBehavior, LayoutSelectionBehavior, RecycleGridLayout ): selected_row = NumericProperty(0) table_data = ObjectProperty(None) def get_nodes(self): nodes = self.get_selectable_nodes() if self.nodes_order_reversed: nodes = nodes[::-1] if not nodes: return None, None selected = self.selected_nodes if not selected: # nothing selected, select the first self.selected_row = 0 self.select_row(nodes) return None, None if len(nodes) == 1: # the only selectable node is selected already return None, None index = selected[-1] if index > len(nodes): last = len(nodes) else: last = nodes.index(index) self.clear_selection() return last, nodes def select_next(self, instance): """Select next row.""" self.table_data = instance last, nodes = self.get_nodes() if not nodes: return if last == len(nodes) - 1: self.selected_row = nodes[0] else: self.selected_row = nodes[last + 1] self.selected_row += self.table_data.total_col_headings self.select_row(nodes) def select_current(self, instance): """Select current row.""" self.table_data = instance last, nodes = self.get_nodes() if not nodes: return self.select_row(nodes) def select_row(self, nodes): col = self.table_data.recycle_data[self.selected_row]["range"] for x in range(col[0], col[1] + 1): self.select_node(nodes[x]) class CellRow( ThemableBehavior, RecycleDataViewBehavior, HoverBehavior, ButtonBehavior, BoxLayout, ): text = StringProperty() # row text table = ObjectProperty() # index = None selected = BooleanProperty(False) selectable = BooleanProperty(True) def on_table(self, instance, table): """Sets padding/spacing to zero if no checkboxes are used for rows.""" if not table.check: self.ids.box.padding = 0 self.ids.box.spacing = 0 def refresh_view_attrs(self, table_data, index, data): """ Called by the :class:`RecycleAdapter` when the view is initially populated with the values from the `data` dictionary for this item. Any pos or size info should be removed because they are set subsequently with :attr:`refresh_view_layout`. :Parameters: `table_data`: :class:`TableData` instance The :class:`TableData` that caused the update. `data`: dict The data dict used to populate this view. """ self.index = index return super().refresh_view_attrs(table_data, index, data) def on_touch_down(self, touch): if super().on_touch_down(touch): if self.table._parent: self.table._parent.dispatch("on_row_press", self) return True def apply_selection(self, table_data, index, is_selected): """Called when list items of table appear on the screen.""" self.selected = is_selected # Set checkboxes. if table_data.check: if self.text in table_data.data_first_cells: self.ids.check.size = (dp(32), dp(32)) self.ids.check.opacity = 1 self.ids.box.spacing = dp(16) self.ids.box.padding[0] = dp(8) else: self.ids.check.size = (0, 0) self.ids.check.opacity = 0 self.ids.box.spacing = 0 self.ids.box.padding[0] = 0 # Set checkboxes state. if table_data._rows_number in table_data.current_selection_check: for index in table_data.current_selection_check[ table_data._rows_number ]: if ( self.index in table_data.current_selection_check[ table_data._rows_number ] ): self.ids.check.state = "down" else: self.ids.check.state = "normal" else: self.ids.check.state = "normal" def select_check(self, active): """Called upon activation/deactivation of the checkbox.""" if active and self.index not in self.table.current_selection_check: if ( self.table._rows_number not in self.table.current_selection_check ): self.table.current_selection_check[self.table._rows_number] = [] if ( self.index not in self.table.current_selection_check[ self.table._rows_number ] ): self.table.current_selection_check[ self.table._rows_number ].append(self.index) else: if self.table._rows_number in self.table.current_selection_check: if ( self.index in self.table.current_selection_check[ self.table._rows_number ] and not active ): self.table.current_selection_check[ self.table._rows_number ].remove(self.index) self.table.get_select_row(self.index) class CellHeader(MDTooltip, BoxLayout): text = StringProperty() # column text class TableHeader(ScrollView): table_data = ObjectProperty() # column_data = ListProperty() # MDDataTable.column_data col_headings = ListProperty() # column names list sort = BooleanProperty(False) # MDDataTable.sort # kivy.uix.gridlayout.GridLayout.cols_minimum cols_minimum = DictProperty() def __init__(self, **kwargs): super().__init__(**kwargs) # Create cells. for i, col_heading in enumerate(self.column_data): self.cols_minimum[i] = col_heading[1] * 5 self.col_headings.append(col_heading[0]) if i: self.ids.header.add_widget( CellHeader(text=col_heading[0], width=self.cols_minimum[i]) ) else: # Sets the text in the first cell. self.ids.first_cell.text = col_heading[0] self.ids.first_cell.ids.separator.height = 0 self.ids.first_cell.width = self.cols_minimum[i] def on_table_data(self, instance, value): """Sets the checkbox in the first cell.""" if self.table_data.check: self.ids.check.size = (dp(32), dp(32)) self.ids.check.opacity = 1 else: self.ids.box.padding[0] = 0 self.ids.box.spacing = 0 def on_sort(self, instance, value): """Rows sorting method.""" Logger.info("TableData: Sorting table items is not implemented") # if not self.sort: # self.ids.sort_button.size = (0, 0) # self.ids.sort_button.opacity = 0 class TableData(RecycleView): recycle_data = ListProperty() # kivy.uix.recycleview.RecycleView.data data_first_cells = ListProperty() # list of first row cells row_data = ListProperty() # MDDataTable.row_data total_col_headings = NumericProperty(0) # TableHeader.col_headings cols_minimum = DictProperty() # TableHeader.cols_minimum table_header = ObjectProperty() # pagination_menu = ObjectProperty() # pagination = ObjectProperty() # check = ObjectProperty() # MDDataTable.check rows_num = NumericProperty() # number of rows displayed on the table page # Open or close the menu for selecting the number of rows displayed # on the table page. pagination_menu_open = BooleanProperty(False) # List of indexes of marked checkboxes. current_selection_check = DictProperty() sort = BooleanProperty() _parent = ObjectProperty() _rows_number = NumericProperty(0) _rows_num = NumericProperty() _current_value = NumericProperty(1) _to_value = NumericProperty() _row_data_parts = ListProperty() def __init__(self, table_header, **kwargs): super().__init__(**kwargs) self.table_header = table_header self.total_col_headings = len(table_header.col_headings) self.cols_minimum = table_header.cols_minimum self.set_row_data() Clock.schedule_once(self.set_default_first_row, 0) def get_select_row(self, index): """Returns the current row with all elements.""" row = [] for data in self.recycle_data: if index in data["range"]: row.append(data["text"]) self._parent.dispatch("on_check_press", row) def set_default_first_row(self, dt): """Set default first row as selected.""" self.ids.row_controller.select_next(self) def sort_by_name(self): """Sorts table data.""" # TODO: implement a rows sorting method. def set_row_data(self): data = [] low = 0 high = self.total_col_headings - 1 self.recycle_data = [] self.data_first_cells = [] if self._row_data_parts: # for row in self.row_data: for row in self._row_data_parts[self._rows_number]: for i in range(len(row)): data.append([row[i], row[0], [low, high]]) low += self.total_col_headings high += self.total_col_headings for j, x in enumerate(data): if x[0] == x[1]: self.data_first_cells.append(x[0]) self.recycle_data.append( { "text": str(x[0]), "Index": str(x[1]), "range": x[2], "selectable": True, "viewclass": "CellRow", "table": self, } ) if not self.table_header.column_data: raise ValueError("Set value for column_data in class TableData") self.data_first_cells.append(self.table_header.column_data[0][0]) def set_text_from_of(self, direction): """Sets the text of the numbers of displayed pages in table.""" if self.pagination: if direction == "forward": if ( len(self._row_data_parts[self._rows_number]) < self._to_value ): self._current_value = self._current_value + self.rows_num else: self._current_value = self._current_value + len( self._row_data_parts[self._rows_number] ) self._to_value = self._to_value + len( self._row_data_parts[self._rows_number] ) if direction == "back": self._current_value = self._current_value - len( self._row_data_parts[self._rows_number] ) self._to_value = self._to_value - len( self._row_data_parts[self._rows_number + 1] ) if direction == "increment": self._current_value = 1 self._to_value = self.rows_num + self._current_value - 1 self.pagination.ids.label_rows_per_page.text = f"{self._current_value}-{self._to_value} of {len(self.row_data)}" def select_all(self, state): """Sets the checkboxes of all rows to the active/inactive position.""" # FIXME: fix the work of selecting all cells.. for i, data in enumerate(self.recycle_data): opts = self.layout_manager.view_opts cell_row_obj = self.view_adapter.get_view( i, self.data[i], opts[i]["viewclass"] ) cell_row_obj.ids.check.state = state self.on_mouse_select(cell_row_obj) cell_row_obj.select_check(True if state == "down" else False) def close_pagination_menu(self, *args): """Called when the pagination menu window is closed.""" self.pagination_menu_open = False def open_pagination_menu(self): """Open pagination menu window.""" if self.pagination_menu.items: self.pagination_menu_open = True self.pagination_menu.open() def set_number_displayed_lines(self, instance_menu, instance_menu_item): """ Called when the user sets the number of pages displayed in the table. """ self.rows_num = int(instance_menu_item.text) self.set_row_data() self.set_text_from_of("increment") def set_next_row_data_parts(self, direction): """Called when switching the pages of the table.""" if direction == "forward": self._rows_number += 1 self.pagination.ids.button_back.disabled = False elif direction == "back": self._rows_number -= 1 self.pagination.ids.button_forward.disabled = False self.set_row_data() self.set_text_from_of(direction) if self._to_value == len(self.row_data): self.pagination.ids.button_forward.disabled = True if self._current_value == 1: self.pagination.ids.button_back.disabled = True def _split_list_into_equal_parts(self, lst, parts): for i in range(0, len(lst), parts): yield lst[i : i + parts] def on_mouse_select(self, instance): """Called on the ``on_enter`` event of the :class:`~CellRow` class""" if not self.pagination_menu_open: if self.ids.row_controller.selected_row != instance.index: self.ids.row_controller.selected_row = instance.index self.ids.row_controller.select_current(self) def on_rows_num(self, instance, value): if not self._to_value: self._to_value = value self._rows_number = 0 self._row_data_parts = list( self._split_list_into_equal_parts(self.row_data, value) ) # def on_pagination(self, instance_table, instance_pagination): # if len(self._row_data_parts) <= self._to_value: # instance_pagination.ids.button_forward.disabled = True class TablePagination(ThemableBehavior, MDBoxLayout): """Pagination Container.""" table_data = ObjectProperty() # class MDDataTable(BaseDialog): """ :Events: :attr:`on_row_press` Called when a table row is clicked. :attr:`on_check_press` Called when the check box in the table row is checked. .. rubric:: Use events as follows .. code-block:: python from kivy.metrics import dp from kivymd.app import MDApp from kivymd.uix.datatables import MDDataTable class Example(MDApp): def build(self): self.data_tables = MDDataTable( size_hint=(0.9, 0.6), use_pagination=True, check=True, column_data=[ ("No.", dp(30)), ("Column 1", dp(30)), ("Column 2", dp(30)), ("Column 3", dp(30)), ("Column 4", dp(30)), ("Column 5", dp(30)), ], row_data=[ (f"{i + 1}", "2.23", "3.65", "44.1", "0.45", "62.5") for i in range(50) ], ) self.data_tables.bind(on_row_press=self.on_row_press) self.data_tables.bind(on_check_press=self.on_check_press) def on_start(self): self.data_tables.open() def on_row_press(self, instance_table, instance_row): '''Called when a table row is clicked.''' print(instance_table, instance_row) def on_check_press(self, instance_table, current_row): '''Called when the check box in the table row is checked.''' print(instance_table, current_row) Example().run() """ column_data = ListProperty() """ Data for header columns. .. code-block:: python from kivy.metrics import dp from kivymd.app import MDApp from kivymd.uix.datatables import MDDataTable class Example(MDApp): def build(self): self.data_tables = MDDataTable( size_hint=(0.9, 0.6), # name column, width column column_data=[ ("Column 1", dp(30)), ("Column 2", dp(30)), ("Column 3", dp(30)), ("Column 4", dp(30)), ("Column 5", dp(30)), ("Column 6", dp(30)), ], ) def on_start(self): self.data_tables.open() Example().run() .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-column-data.png :align: center :attr:`column_data` is an :class:`~kivy.properties.ListProperty` and defaults to `[]`. """ row_data = ListProperty() """ Data for rows. .. code-block:: python from kivy.metrics import dp from kivymd.app import MDApp from kivymd.uix.datatables import MDDataTable class Example(MDApp): def build(self): self.data_tables = MDDataTable( size_hint=(0.9, 0.6), column_data=[ ("Column 1", dp(30)), ("Column 2", dp(30)), ("Column 3", dp(30)), ("Column 4", dp(30)), ("Column 5", dp(30)), ("Column 6", dp(30)), ], row_data=[ # The number of elements must match the length # of the `column_data` list. ("1", "2", "3", "4", "5", "6"), ("1", "2", "3", "4", "5", "6"), ], ) def on_start(self): self.data_tables.open() Example().run() .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-row-data.png :align: center :attr:`row_data` is an :class:`~kivy.properties.ListProperty` and defaults to `[]`. """ sort = BooleanProperty(False) """ Whether to display buttons for sorting table items. :attr:`sort` is an :class:`~kivy.properties.BooleanProperty` and defaults to `False`. """ check = BooleanProperty(False) """ Use or not use checkboxes for rows. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-check.gif :align: center :attr:`check` is an :class:`~kivy.properties.BooleanProperty` and defaults to `False`. """ use_pagination = BooleanProperty(False) """ Use page pagination for table or not. .. code-block:: python from kivymd.app import MDApp from kivymd.uix.datatables import MDDataTable class Example(MDApp): def build(self): self.data_tables = MDDataTable( size_hint=(0.9, 0.6), use_pagination=True, column_data=[ ("No.", dp(30)), ("Column 1", dp(30)), ("Column 2", dp(30)), ("Column 3", dp(30)), ("Column 4", dp(30)), ("Column 5", dp(30)), ], row_data=[ (f"{i + 1}", "1", "2", "3", "4", "5") for i in range(50) ], ) def on_start(self): self.data_tables.open() Example().run() .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-use-pagination.png :align: center :attr:`use_pagination` is an :class:`~kivy.properties.BooleanProperty` and defaults to `False`. """ rows_num = NumericProperty(5) """ The number of rows displayed on one page of the table. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-use-pagination.gif :align: center :attr:`rows_num` is an :class:`~kivy.properties.NumericProperty` and defaults to `10`. """ pagination_menu_pos = OptionProperty("center", options=["center", "auto"]) """ Menu position for selecting the number of displayed rows. Available options are `'center'`, `'auto'`. .. rubric:: Center .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-menu-pos-center.png :align: center .. rubric:: Auto .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-menu-pos-auto.png :align: center :attr:`pagination_menu_pos` is an :class:`~kivy.properties.OptionProperty` and defaults to `'center'`. """ pagination_menu_height = NumericProperty("140dp") """ Menu height for selecting the number of displayed rows. .. rubric:: 140dp .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-menu-height-140.png :align: center .. rubric:: 240dp .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/data-tables-menu-height-240.png :align: center :attr:`pagination_menu_height` is an :class:`~kivy.properties.NumericProperty` and defaults to `'140dp'`. """ background_color = ListProperty([0, 0, 0, 0]) """ Background color in the format (r, g, b, a). See :attr:`~kivy.uix.modalview.ModalView.background_color`. :attr:`background_color` is a :class:`~kivy.properties.ListProperty` and defaults to [0, 0, 0, .7]. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.register_event_type("on_row_press") self.register_event_type("on_check_press") self.header = TableHeader(column_data=self.column_data, sort=self.sort) self.table_data = TableData( self.header, row_data=self.row_data, check=self.check, rows_num=self.rows_num, _parent=self, ) self.pagination = TablePagination(table_data=self.table_data) self.table_data.pagination = self.pagination self.header.table_data = self.table_data self.table_data.fbind("scroll_x", self._scroll_with_header) self.ids.container.add_widget(self.header) self.ids.container.add_widget(self.table_data) if self.use_pagination: self.ids.container.add_widget(self.pagination) Clock.schedule_once(self.create_pagination_menu, 0.5) def on_row_press(self, *args): """Called when a table row is clicked.""" def on_check_press(self, *args): """Called when the check box in the table row is checked.""" def _scroll_with_header(self, instance, value): self.header.scroll_x = value def create_pagination_menu(self, interval): menu_items = [ {"text": f"{i}"} for i in range( self.rows_num, len(self.row_data) // 2, self.rows_num ) ] pagination_menu = MDDropdownMenu( caller=self.pagination.ids.drop_item, items=menu_items, position=self.pagination_menu_pos, max_height=self.pagination_menu_height, width_mult=2, ) pagination_menu.bind( on_release=self.table_data.set_number_displayed_lines, on_dismiss=self.table_data.close_pagination_menu, ) self.table_data.pagination_menu = pagination_menu