supervisely 6.73.410__py3-none-any.whl → 6.73.470__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of supervisely might be problematic. Click here for more details.

Files changed (190) hide show
  1. supervisely/__init__.py +136 -1
  2. supervisely/_utils.py +81 -0
  3. supervisely/annotation/json_geometries_map.py +2 -0
  4. supervisely/annotation/label.py +80 -3
  5. supervisely/api/annotation_api.py +9 -9
  6. supervisely/api/api.py +67 -43
  7. supervisely/api/app_api.py +72 -5
  8. supervisely/api/dataset_api.py +108 -33
  9. supervisely/api/entity_annotation/figure_api.py +113 -49
  10. supervisely/api/image_api.py +82 -0
  11. supervisely/api/module_api.py +10 -0
  12. supervisely/api/nn/deploy_api.py +15 -9
  13. supervisely/api/nn/ecosystem_models_api.py +201 -0
  14. supervisely/api/nn/neural_network_api.py +12 -3
  15. supervisely/api/pointcloud/pointcloud_api.py +38 -0
  16. supervisely/api/pointcloud/pointcloud_episode_annotation_api.py +3 -0
  17. supervisely/api/project_api.py +213 -6
  18. supervisely/api/task_api.py +11 -1
  19. supervisely/api/video/video_annotation_api.py +4 -2
  20. supervisely/api/video/video_api.py +79 -1
  21. supervisely/api/video/video_figure_api.py +24 -11
  22. supervisely/api/volume/volume_api.py +38 -0
  23. supervisely/app/__init__.py +1 -1
  24. supervisely/app/content.py +14 -6
  25. supervisely/app/fastapi/__init__.py +1 -0
  26. supervisely/app/fastapi/custom_static_files.py +1 -1
  27. supervisely/app/fastapi/multi_user.py +88 -0
  28. supervisely/app/fastapi/subapp.py +175 -42
  29. supervisely/app/fastapi/templating.py +1 -1
  30. supervisely/app/fastapi/websocket.py +77 -9
  31. supervisely/app/singleton.py +21 -0
  32. supervisely/app/v1/app_service.py +18 -2
  33. supervisely/app/v1/constants.py +7 -1
  34. supervisely/app/widgets/__init__.py +11 -1
  35. supervisely/app/widgets/agent_selector/template.html +1 -0
  36. supervisely/app/widgets/card/card.py +20 -0
  37. supervisely/app/widgets/dataset_thumbnail/dataset_thumbnail.py +11 -2
  38. supervisely/app/widgets/dataset_thumbnail/template.html +3 -1
  39. supervisely/app/widgets/deploy_model/deploy_model.py +750 -0
  40. supervisely/app/widgets/dialog/dialog.py +12 -0
  41. supervisely/app/widgets/dialog/template.html +2 -1
  42. supervisely/app/widgets/dropdown_checkbox_selector/__init__.py +0 -0
  43. supervisely/app/widgets/dropdown_checkbox_selector/dropdown_checkbox_selector.py +87 -0
  44. supervisely/app/widgets/dropdown_checkbox_selector/template.html +12 -0
  45. supervisely/app/widgets/ecosystem_model_selector/__init__.py +0 -0
  46. supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +195 -0
  47. supervisely/app/widgets/experiment_selector/experiment_selector.py +454 -263
  48. supervisely/app/widgets/fast_table/fast_table.py +713 -126
  49. supervisely/app/widgets/fast_table/script.js +492 -95
  50. supervisely/app/widgets/fast_table/style.css +54 -0
  51. supervisely/app/widgets/fast_table/template.html +45 -5
  52. supervisely/app/widgets/heatmap/__init__.py +0 -0
  53. supervisely/app/widgets/heatmap/heatmap.py +523 -0
  54. supervisely/app/widgets/heatmap/script.js +378 -0
  55. supervisely/app/widgets/heatmap/style.css +227 -0
  56. supervisely/app/widgets/heatmap/template.html +21 -0
  57. supervisely/app/widgets/input_tag/input_tag.py +102 -15
  58. supervisely/app/widgets/input_tag_list/__init__.py +0 -0
  59. supervisely/app/widgets/input_tag_list/input_tag_list.py +274 -0
  60. supervisely/app/widgets/input_tag_list/template.html +70 -0
  61. supervisely/app/widgets/radio_table/radio_table.py +10 -2
  62. supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
  63. supervisely/app/widgets/radio_tabs/template.html +1 -0
  64. supervisely/app/widgets/select/select.py +6 -4
  65. supervisely/app/widgets/select_dataset/select_dataset.py +6 -0
  66. supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +83 -7
  67. supervisely/app/widgets/table/table.py +68 -13
  68. supervisely/app/widgets/tabs/tabs.py +22 -6
  69. supervisely/app/widgets/tabs/template.html +5 -1
  70. supervisely/app/widgets/transfer/style.css +3 -0
  71. supervisely/app/widgets/transfer/template.html +3 -1
  72. supervisely/app/widgets/transfer/transfer.py +48 -45
  73. supervisely/app/widgets/tree_select/tree_select.py +2 -0
  74. supervisely/convert/image/csv/csv_converter.py +24 -15
  75. supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +43 -41
  76. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +75 -51
  77. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +137 -124
  78. supervisely/convert/video/video_converter.py +2 -2
  79. supervisely/geometry/polyline_3d.py +110 -0
  80. supervisely/io/env.py +161 -1
  81. supervisely/nn/artifacts/__init__.py +1 -1
  82. supervisely/nn/artifacts/artifacts.py +10 -2
  83. supervisely/nn/artifacts/detectron2.py +1 -0
  84. supervisely/nn/artifacts/hrda.py +1 -0
  85. supervisely/nn/artifacts/mmclassification.py +20 -0
  86. supervisely/nn/artifacts/mmdetection.py +5 -3
  87. supervisely/nn/artifacts/mmsegmentation.py +1 -0
  88. supervisely/nn/artifacts/ritm.py +1 -0
  89. supervisely/nn/artifacts/rtdetr.py +1 -0
  90. supervisely/nn/artifacts/unet.py +1 -0
  91. supervisely/nn/artifacts/utils.py +3 -0
  92. supervisely/nn/artifacts/yolov5.py +2 -0
  93. supervisely/nn/artifacts/yolov8.py +1 -0
  94. supervisely/nn/benchmark/semantic_segmentation/metric_provider.py +18 -18
  95. supervisely/nn/experiments.py +9 -0
  96. supervisely/nn/inference/cache.py +37 -17
  97. supervisely/nn/inference/gui/serving_gui_template.py +39 -13
  98. supervisely/nn/inference/inference.py +953 -211
  99. supervisely/nn/inference/inference_request.py +15 -8
  100. supervisely/nn/inference/instance_segmentation/instance_segmentation.py +1 -0
  101. supervisely/nn/inference/object_detection/object_detection.py +1 -0
  102. supervisely/nn/inference/predict_app/__init__.py +0 -0
  103. supervisely/nn/inference/predict_app/gui/__init__.py +0 -0
  104. supervisely/nn/inference/predict_app/gui/classes_selector.py +160 -0
  105. supervisely/nn/inference/predict_app/gui/gui.py +915 -0
  106. supervisely/nn/inference/predict_app/gui/input_selector.py +344 -0
  107. supervisely/nn/inference/predict_app/gui/model_selector.py +77 -0
  108. supervisely/nn/inference/predict_app/gui/output_selector.py +179 -0
  109. supervisely/nn/inference/predict_app/gui/preview.py +93 -0
  110. supervisely/nn/inference/predict_app/gui/settings_selector.py +881 -0
  111. supervisely/nn/inference/predict_app/gui/tags_selector.py +110 -0
  112. supervisely/nn/inference/predict_app/gui/utils.py +399 -0
  113. supervisely/nn/inference/predict_app/predict_app.py +176 -0
  114. supervisely/nn/inference/session.py +47 -39
  115. supervisely/nn/inference/tracking/bbox_tracking.py +5 -1
  116. supervisely/nn/inference/tracking/point_tracking.py +5 -1
  117. supervisely/nn/inference/tracking/tracker_interface.py +4 -0
  118. supervisely/nn/inference/uploader.py +9 -5
  119. supervisely/nn/model/model_api.py +44 -22
  120. supervisely/nn/model/prediction.py +15 -1
  121. supervisely/nn/model/prediction_session.py +70 -14
  122. supervisely/nn/prediction_dto.py +7 -0
  123. supervisely/nn/tracker/__init__.py +6 -8
  124. supervisely/nn/tracker/base_tracker.py +54 -0
  125. supervisely/nn/tracker/botsort/__init__.py +1 -0
  126. supervisely/nn/tracker/botsort/botsort_config.yaml +30 -0
  127. supervisely/nn/tracker/botsort/osnet_reid/__init__.py +0 -0
  128. supervisely/nn/tracker/botsort/osnet_reid/osnet.py +566 -0
  129. supervisely/nn/tracker/botsort/osnet_reid/osnet_reid_interface.py +88 -0
  130. supervisely/nn/tracker/botsort/tracker/__init__.py +0 -0
  131. supervisely/nn/tracker/{bot_sort → botsort/tracker}/basetrack.py +1 -2
  132. supervisely/nn/tracker/{utils → botsort/tracker}/gmc.py +51 -59
  133. supervisely/nn/tracker/{deep_sort/deep_sort → botsort/tracker}/kalman_filter.py +71 -33
  134. supervisely/nn/tracker/botsort/tracker/matching.py +202 -0
  135. supervisely/nn/tracker/{bot_sort/bot_sort.py → botsort/tracker/mc_bot_sort.py} +68 -81
  136. supervisely/nn/tracker/botsort_tracker.py +273 -0
  137. supervisely/nn/tracker/calculate_metrics.py +264 -0
  138. supervisely/nn/tracker/utils.py +273 -0
  139. supervisely/nn/tracker/visualize.py +520 -0
  140. supervisely/nn/training/gui/gui.py +152 -49
  141. supervisely/nn/training/gui/hyperparameters_selector.py +1 -1
  142. supervisely/nn/training/gui/model_selector.py +8 -6
  143. supervisely/nn/training/gui/train_val_splits_selector.py +144 -71
  144. supervisely/nn/training/gui/training_artifacts.py +3 -1
  145. supervisely/nn/training/train_app.py +225 -46
  146. supervisely/project/pointcloud_episode_project.py +12 -8
  147. supervisely/project/pointcloud_project.py +12 -8
  148. supervisely/project/project.py +221 -75
  149. supervisely/template/experiment/experiment.html.jinja +105 -55
  150. supervisely/template/experiment/experiment_generator.py +258 -112
  151. supervisely/template/experiment/header.html.jinja +31 -13
  152. supervisely/template/experiment/sly-style.css +7 -2
  153. supervisely/versions.json +3 -1
  154. supervisely/video/sampling.py +42 -20
  155. supervisely/video/video.py +41 -12
  156. supervisely/video_annotation/video_figure.py +38 -4
  157. supervisely/volume/stl_converter.py +2 -0
  158. supervisely/worker_api/agent_rpc.py +24 -1
  159. supervisely/worker_api/rpc_servicer.py +31 -7
  160. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/METADATA +22 -14
  161. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/RECORD +167 -148
  162. supervisely_lib/__init__.py +6 -1
  163. supervisely/app/widgets/experiment_selector/style.css +0 -27
  164. supervisely/app/widgets/experiment_selector/template.html +0 -61
  165. supervisely/nn/tracker/bot_sort/__init__.py +0 -21
  166. supervisely/nn/tracker/bot_sort/fast_reid_interface.py +0 -152
  167. supervisely/nn/tracker/bot_sort/matching.py +0 -127
  168. supervisely/nn/tracker/bot_sort/sly_tracker.py +0 -401
  169. supervisely/nn/tracker/deep_sort/__init__.py +0 -6
  170. supervisely/nn/tracker/deep_sort/deep_sort/__init__.py +0 -1
  171. supervisely/nn/tracker/deep_sort/deep_sort/detection.py +0 -49
  172. supervisely/nn/tracker/deep_sort/deep_sort/iou_matching.py +0 -81
  173. supervisely/nn/tracker/deep_sort/deep_sort/linear_assignment.py +0 -202
  174. supervisely/nn/tracker/deep_sort/deep_sort/nn_matching.py +0 -176
  175. supervisely/nn/tracker/deep_sort/deep_sort/track.py +0 -166
  176. supervisely/nn/tracker/deep_sort/deep_sort/tracker.py +0 -145
  177. supervisely/nn/tracker/deep_sort/deep_sort.py +0 -301
  178. supervisely/nn/tracker/deep_sort/generate_clip_detections.py +0 -90
  179. supervisely/nn/tracker/deep_sort/preprocessing.py +0 -70
  180. supervisely/nn/tracker/deep_sort/sly_tracker.py +0 -273
  181. supervisely/nn/tracker/tracker.py +0 -285
  182. supervisely/nn/tracker/utils/kalman_filter.py +0 -492
  183. supervisely/nn/tracking/__init__.py +0 -1
  184. supervisely/nn/tracking/boxmot.py +0 -114
  185. supervisely/nn/tracking/tracking.py +0 -24
  186. /supervisely/{nn/tracker/utils → app/widgets/deploy_model}/__init__.py +0 -0
  187. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/LICENSE +0 -0
  188. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/WHEEL +0 -0
  189. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/entry_points.txt +0 -0
  190. {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/top_level.txt +0 -0
@@ -46,6 +46,21 @@ class FastTable(Widget):
46
46
  :type width: str, optional
47
47
  :param widget_id: Unique widget identifier.
48
48
  :type widget_id: str, optional
49
+ :param show_header: Whether to show table header
50
+ :type show_header: bool, optional
51
+ :param is_radio: Enable radio button selection mode (single row selection)
52
+ :type is_radio: bool, optional
53
+ :param is_selectable: Enable multiple row selection
54
+ :type is_selectable: bool, optional
55
+ :param header_left_content: Widget to display in the left side of the header
56
+ :type header_left_content: Widget, optional
57
+ :param header_right_content: Widget to display in the right side of the header
58
+ :type header_right_content: Widget, optional
59
+ :param max_selected_rows: Maximum number of rows that can be selected
60
+ :type max_selected_rows: int, optional
61
+ :param search_position: Position of the search input ("left" or "right")
62
+ :type search_position: Literal["left", "right"], optional
63
+
49
64
 
50
65
  :Usage example:
51
66
  .. code-block:: python
@@ -72,6 +87,7 @@ class FastTable(Widget):
72
87
  """
73
88
 
74
89
  class Routes:
90
+ SELECTION_CHANGED = "selection_changed_cb"
75
91
  ROW_CLICKED = "row_clicked_cb"
76
92
  CELL_CLICKED = "cell_clicked_cb"
77
93
  UPDATE_DATA = "update_data_cb"
@@ -100,6 +116,29 @@ class FastTable(Widget):
100
116
  self.column_name = column_name
101
117
  self.column_value = column_value
102
118
 
119
+ class ColumnData:
120
+ def __init__(self, name, is_widget=False, widget: Widget = None):
121
+ self.name = name
122
+ self.is_widget = is_widget
123
+ self.widget = widget
124
+
125
+ @property
126
+ def widget_html(self):
127
+ html = self.widget.to_html()
128
+ html = html.replace(f".{self.widget.widget_id}", "[JSON.parse(cellValue).widget_id]")
129
+ html = html.replace(
130
+ f"/{self.widget.widget_id}", "/' + JSON.parse(cellValue).widget_id + '"
131
+ )
132
+ if hasattr(self.widget, "_widgets"):
133
+ for i, widget in enumerate(self.widget._widgets):
134
+ html = html.replace(
135
+ f".{widget.widget_id}", f"[JSON.parse(cellValue).widgets[{i}]]"
136
+ )
137
+ html = html.replace(
138
+ f"/{widget.widget_id}", f"/' + JSON.parse(cellValue).widgets[{i}] + '"
139
+ )
140
+ return html
141
+
103
142
  def __init__(
104
143
  self,
105
144
  data: Optional[Union[pd.DataFrame, List]] = None,
@@ -113,23 +152,54 @@ class FastTable(Widget):
113
152
  width: Optional[str] = "auto",
114
153
  widget_id: Optional[str] = None,
115
154
  show_header: bool = True,
155
+ is_radio: bool = False,
156
+ is_selectable: bool = False,
157
+ header_left_content: Optional[Widget] = None,
158
+ header_right_content: Optional[Widget] = None,
159
+ max_selected_rows: Optional[int] = None,
160
+ search_position: Optional[Literal["left", "right"]] = None,
116
161
  ):
117
162
  self._supported_types = tuple([pd.DataFrame, list, type(None)])
118
163
  self._row_click_handled = False
119
164
  self._cell_click_handled = False
120
- self._columns_first_idx = columns
165
+ self._selection_changed_handled = False
166
+ self._columns = columns
167
+ self._columns_data = []
168
+ if columns is None:
169
+ self._columns_first_idx = None
170
+ else:
171
+ self._columns_first_idx = []
172
+ for col in columns:
173
+ if isinstance(col, str):
174
+ self._columns_first_idx.append(col)
175
+ self._columns_data.append(self.ColumnData(name=col))
176
+ elif isinstance(col, tuple):
177
+ self._columns_first_idx.append(col[0])
178
+ self._columns_data.append(
179
+ self.ColumnData(name=col[0], is_widget=True, widget=col[1])
180
+ )
181
+ else:
182
+ raise TypeError(f"Column name must be a string or a tuple, got {type(col)}")
183
+
121
184
  self._columns_options = columns_options
122
185
  self._sorted_data = None
123
186
  self._filtered_data = None
187
+ self._searched_data = None
124
188
  self._active_page = 1
125
189
  self._width = width
126
- self._selected_row = None
190
+ self._selected_rows = []
127
191
  self._selected_cell = None
128
- self._clickable_rows = False
129
- self._clickable_cells = False
192
+ self._clicked_row = None
193
+ self._is_row_clickable = False
194
+ self._is_cell_clickable = False
130
195
  self._search_str = ""
131
196
  self._show_header = show_header
132
197
  self._project_meta = self._unpack_project_meta(project_meta)
198
+ self._header_left_content = header_left_content
199
+ self._header_right_content = header_right_content
200
+ self._max_selected_rows = max_selected_rows
201
+ acceptable_search_positions = ["left", "right"]
202
+ self._search_position = search_position if search_position in acceptable_search_positions else "left"
133
203
 
134
204
  # table_options
135
205
  self._page_size = page_size
@@ -137,6 +207,12 @@ class FastTable(Widget):
137
207
  self._sort_column_idx = sort_column_idx
138
208
  self._sort_order = sort_order
139
209
  self._validate_sort_attrs()
210
+ self._is_radio = is_radio
211
+ self._is_selectable = is_selectable
212
+ self._search_function = self._default_search_function
213
+ self._sort_function = self._default_sort_function
214
+ self._filter_function = self._default_filter_function
215
+ self._filter_value = None
140
216
 
141
217
  # to avoid errors with the duplicated names in columns
142
218
  self._multi_idx_columns = None
@@ -145,6 +221,11 @@ class FastTable(Widget):
145
221
  self._validate_input_data(data)
146
222
  self._source_data = self._prepare_input_data(data)
147
223
 
224
+ # Initialize filtered and searched data for proper initialization
225
+ self._filtered_data = self._filter(self._filter_value)
226
+ self._searched_data = self._search(self._search_str)
227
+ self._sorted_data = self._sort_table_data(self._searched_data)
228
+
148
229
  # prepare parsed_source_data, sliced_data, parsed_active_data
149
230
  (
150
231
  self._parsed_source_data,
@@ -154,6 +235,9 @@ class FastTable(Widget):
154
235
 
155
236
  self._rows_total = len(self._parsed_source_data["data"])
156
237
 
238
+ if self._is_radio and self._rows_total > 0:
239
+ self._selected_rows = [self._parsed_source_data["data"][0]]
240
+
157
241
  super().__init__(widget_id=widget_id, file_path=__file__)
158
242
 
159
243
  script_path = "./sly/css/app/widgets/fast_table/script.js"
@@ -163,27 +247,34 @@ class FastTable(Widget):
163
247
  server = self._sly_app.get_server()
164
248
 
165
249
  @server.post(filter_changed_route_path)
166
- def _filter_changed():
167
- self._active_page = StateJson()[self.widget_id]["page"]
168
- self._sort_order = StateJson()[self.widget_id]["sort"]["order"]
169
- self._sort_column_idx = StateJson()[self.widget_id]["sort"]["column"]
170
- search_value = StateJson()[self.widget_id]["search"]
171
- self._filtered_data = self.search(search_value)
172
- self._rows_total = len(self._filtered_data)
173
-
174
- if self._rows_total > 0 and self._active_page == 0: # if previous filtered data was empty
175
- self._active_page = 1
176
- StateJson()[self.widget_id]["page"] = self._active_page
177
-
178
- self._sorted_data = self._sort_table_data(self._filtered_data)
179
- self._sliced_data = self._slice_table_data(
180
- self._sorted_data, actual_page=self._active_page
181
- )
182
- self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
183
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
184
- DataJson()[self.widget_id]["total"] = self._rows_total
185
- DataJson().send_changes()
186
- StateJson().send_changes()
250
+ def _filter_changed_handler():
251
+ self._refresh()
252
+
253
+ def _refresh(self):
254
+ # TODO sort widgets
255
+ self._active_page = StateJson()[self.widget_id]["page"]
256
+ self._sort_order = StateJson()[self.widget_id]["sort"]["order"]
257
+ self._sort_column_idx = StateJson()[self.widget_id]["sort"]["column"]
258
+ search_value = StateJson()[self.widget_id]["search"]
259
+ self._filtered_data = self._filter(self._filter_value)
260
+ self._searched_data = self._search(search_value)
261
+ self._rows_total = len(self._searched_data)
262
+
263
+ # if active page is greater than the number of pages (e.g. after filtering)
264
+ max_page = (self._rows_total - 1) // self._page_size + 1
265
+ if (self._rows_total > 0 and self._active_page == 0) or self._active_page > max_page:
266
+ self._active_page = 1
267
+ StateJson()[self.widget_id]["page"] = self._active_page
268
+
269
+ self._sorted_data = self._sort_table_data(self._searched_data)
270
+ self._sliced_data = self._slice_table_data(self._sorted_data, actual_page=self._active_page)
271
+ self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
272
+ StateJson().send_changes()
273
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
274
+ DataJson()[self.widget_id]["total"] = self._rows_total
275
+ DataJson().send_changes()
276
+ StateJson()["reactToChanges"] = True
277
+ StateJson().send_changes()
187
278
 
188
279
  def get_json_data(self) -> Dict[str, Any]:
189
280
  """Returns dictionary with widget data, which defines the appearance and behavior of the widget.
@@ -197,32 +288,44 @@ class FastTable(Widget):
197
288
  - isRowClickable: whether rows are clickable
198
289
  - isCellClickable: whether cells are clickable
199
290
  - fixColumns: number of fixed columns
291
+ - isRadio: whether radio button selection mode is enabled
292
+ - isRowSelectable: whether multiple row selection is enabled
293
+ - maxSelectedRows: maximum number of rows that can be selected
294
+ - searchPosition: position of the search input ("left" or "right")
200
295
  - pageSize: number of rows per page
296
+ - showHeader: whether to show table header
297
+ - selectionChangedHandled: whether selection changed event listener is set
201
298
 
202
299
  :return: Dictionary with widget data
203
300
  :rtype: Dict[str, Any]
204
301
  """
205
302
  return {
206
- "data": self._parsed_active_data["data"],
303
+ "data": list(self._parsed_active_data["data"]),
207
304
  "columns": self._parsed_source_data["columns"],
208
305
  "projectMeta": self._project_meta,
209
306
  "columnsOptions": self._columns_options,
210
307
  "total": self._rows_total,
211
308
  "options": {
212
- "isRowClickable": self._clickable_rows,
213
- "isCellClickable": self._clickable_cells,
309
+ "isRowClickable": self._is_row_clickable,
310
+ "isCellClickable": self._is_cell_clickable,
214
311
  "fixColumns": self._fix_columns,
312
+ "isRadio": self._is_radio,
313
+ "isRowSelectable": self._is_selectable,
314
+ "maxSelectedRows": self._max_selected_rows,
315
+ "searchPosition": self._search_position,
215
316
  },
216
317
  "pageSize": self._page_size,
217
318
  "showHeader": self._show_header,
319
+ "selectionChangedHandled": self._selection_changed_handled,
218
320
  }
219
321
 
220
322
  def get_json_state(self) -> Dict[str, Any]:
221
323
  """Returns dictionary with widget state.
222
324
  Dictionary contains the following fields:
223
325
  - search: search string
224
- - selectedRow: selected row
326
+ - selectedRows: selected rows
225
327
  - selectedCell: selected cell
328
+ - clickedRow: clicked row
226
329
  - page: active page
227
330
  - sort: sorting options with the following fields:
228
331
  - column: index of the column to sort by
@@ -233,8 +336,9 @@ class FastTable(Widget):
233
336
  """
234
337
  return {
235
338
  "search": self._search_str,
236
- "selectedRow": self._selected_row,
339
+ "selectedRows": self._selected_rows,
237
340
  "selectedCell": self._selected_cell,
341
+ "clickedRow": self._clicked_row,
238
342
  "page": self._active_page,
239
343
  "sort": {
240
344
  "column": self._sort_column_idx,
@@ -289,37 +393,128 @@ class FastTable(Widget):
289
393
  self._page_size = size
290
394
  DataJson()[self.widget_id]["pageSize"] = self._page_size
291
395
 
292
- def read_json(self, data: Dict, meta: Dict = None) -> None:
396
+ def set_sort(
397
+ self, func: Callable[[pd.DataFrame, int, Optional[Literal["asc", "desc"]]], pd.DataFrame]
398
+ ) -> None:
399
+ """Sets custom sort function for the table.
400
+
401
+ :param func: Custom sort function
402
+ :type func: Callable[[pd.DataFrame, int, Optional[Literal["asc", "desc"]]], pd.DataFrame]
403
+ """
404
+ self._sort_function = func
405
+
406
+ def set_search(self, func: Callable[[pd.DataFrame, str], pd.DataFrame]) -> None:
407
+ """Sets custom search function for the table.
408
+
409
+ :param func: Custom search function
410
+ :type func: Callable[[pd.DataFrame, str], pd.DataFrame]
411
+ """
412
+ self._search_function = func
413
+
414
+ def set_filter(self, filter_function: Callable[[pd.DataFrame, Any], pd.DataFrame]) -> None:
415
+ """Sets a custom filter function for the table.
416
+ first argument is a DataFrame, second argument is a filter value.
417
+
418
+ :param filter_function: Custom filter function
419
+ :type filter_function: Callable[[pd.DataFrame, Any], pd.DataFrame]
420
+ """
421
+ if filter_function is None:
422
+ filter_function = self._default_filter_function
423
+ self._filter_function = filter_function
424
+
425
+ def read_json(self, data: Dict, meta: Dict = None, custom_columns: Optional[List[Union[str, tuple]]] = None) -> None:
293
426
  """Replace table data with options and project meta in the widget
294
427
 
295
- :param data: Table data with options
428
+ More about options in `Developer Portal <https://developer.supervisely.com/app-development/widgets/tables/fasttable#read_json>`_
429
+
430
+ :param data: Table data with options:
431
+ - data: table data
432
+ - columns: list of column names
433
+ - projectMeta: project meta information - if provided
434
+ - columnsOptions: list of dicts with options for each column
435
+ - total: total number of rows
436
+ - options: table options with the following fields:
437
+ - isRowClickable: whether rows are clickable
438
+ - isCellClickable: whether cells are clickable
439
+ - fixColumns: number of fixed columns
440
+ - isRadio: whether radio button selection mode is enabled
441
+ - isRowSelectable: whether multiple row selection is enabled
442
+ - maxSelectedRows: maximum number of rows that can be selected
443
+ - searchPosition: position of the search input ("left" or "right")
444
+ - pageSize: number of rows per page
445
+ - showHeader: whether to show table header
446
+ - selectionChangedHandled: whether selection changed event listener is set
447
+
296
448
  :type data: dict
297
449
  :param meta: Project meta information
298
450
  :type meta: dict
451
+ :param custom_columns: List of column names. Can include widgets as tuples (column_name, widget)
452
+ :type custom_columns: List[Union[str, tuple]], optional
453
+
454
+ Example of data dict:
455
+ .. code-block:: python
456
+
457
+ data = {
458
+ "data": [["apple", "21"], ["banana", "15"]],
459
+ "columns": ["Class", "Items"],
460
+ "columnsOptions": [
461
+ { "type": "class"},
462
+ { "maxValue": 21, "postfix": "pcs", "tooltip": "description text", "subtitle": "boxes" }
463
+ ],
464
+ "options": {
465
+ "isRowClickable": True,
466
+ "isCellClickable": True,
467
+ "fixColumns": 1,
468
+ "isRadio": False,
469
+ "isRowSelectable": True,
470
+ "maxSelectedRows": 5,
471
+ "searchPosition": "right",
472
+ "sort": {"column": 0, "order": "asc"},
473
+ },
474
+ }
299
475
  """
300
- self._columns_first_idx = self._prepare_json_data(data, "columns")
301
476
  self._columns_options = self._prepare_json_data(data, "columnsOptions")
477
+ self._read_custom_columns(custom_columns)
478
+ if not self._columns_first_idx:
479
+ self._columns_first_idx = self._prepare_json_data(data, "columns")
302
480
  self._table_options = self._prepare_json_data(data, "options")
303
481
  self._project_meta = self._unpack_project_meta(meta)
304
- self._parsed_source_data = data.get("data", None)
305
- self._source_data = self._prepare_input_data(self._parsed_source_data)
306
- self._sliced_data = self._slice_table_data(self._source_data)
307
- self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
482
+ table_data = data.get("data", None)
483
+ self._validate_input_data(table_data)
484
+ self._source_data = self._prepare_input_data(table_data)
485
+
308
486
  init_options = DataJson()[self.widget_id]["options"]
309
487
  init_options.update(self._table_options)
310
488
  sort = init_options.pop("sort", {"column": None, "order": None})
311
- page_size = init_options.pop("pageSize", 10)
312
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
489
+ self._active_page = 1
490
+ self._sort_column_idx = sort.get("column", None)
491
+ if self._sort_column_idx is not None and self._sort_column_idx > len(self._columns_first_idx) - 1:
492
+ self._sort_column_idx = None
493
+ self._sort_order = sort.get("order", None)
494
+ self._page_size = init_options.pop("pageSize", 10)
495
+
496
+ # Apply sorting before preparing working data
497
+ self._sorted_data = self._sort_table_data(self._source_data)
498
+ self._sliced_data = self._slice_table_data(self._sorted_data, actual_page=self._active_page)
499
+ self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
500
+ self._parsed_source_data = self._unpack_pandas_table_data(self._source_data)
501
+ self._rows_total = len(self._parsed_source_data["data"])
502
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
313
503
  DataJson()[self.widget_id]["columns"] = self._parsed_active_data["columns"]
314
504
  DataJson()[self.widget_id]["columnsOptions"] = self._columns_options
315
505
  DataJson()[self.widget_id]["options"] = init_options
316
506
  DataJson()[self.widget_id]["total"] = len(self._source_data)
317
- DataJson()[self.widget_id]["pageSize"] = page_size
507
+ DataJson()[self.widget_id]["pageSize"] = self._page_size
318
508
  DataJson()[self.widget_id]["projectMeta"] = self._project_meta
319
- StateJson()[self.widget_id]["sort"] = sort
509
+ StateJson()[self.widget_id]["sort"]["column"] = self._sort_column_idx
510
+ StateJson()[self.widget_id]["sort"]["order"] = self._sort_order
511
+ StateJson()[self.widget_id]["page"] = self._active_page
512
+ StateJson()[self.widget_id]["selectedRows"] = []
513
+ StateJson()[self.widget_id]["selectedCell"] = None
514
+ self._maybe_update_selected_row()
515
+ self._validate_sort_attrs()
320
516
  DataJson().send_changes()
321
517
  StateJson().send_changes()
322
- self.clear_selection()
323
518
 
324
519
  def read_pandas(self, data: pd.DataFrame) -> None:
325
520
  """Replace table data (rows and columns) in the widget.
@@ -332,7 +527,8 @@ class FastTable(Widget):
332
527
  self._sliced_data = self._slice_table_data(self._sorted_data)
333
528
  self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
334
529
  self._parsed_source_data = self._unpack_pandas_table_data(self._source_data)
335
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
530
+ self._rows_total = len(self._parsed_source_data["data"])
531
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
336
532
  DataJson()[self.widget_id]["columns"] = self._parsed_active_data["columns"]
337
533
  DataJson()[self.widget_id]["total"] = len(self._source_data)
338
534
  DataJson().send_changes()
@@ -344,6 +540,24 @@ class FastTable(Widget):
344
540
  def to_json(self, active_page: Optional[bool] = False) -> Dict[str, Any]:
345
541
  """Export table data with current options as dict.
346
542
 
543
+ Dictionary contains the following fields:
544
+ - data: table data
545
+ - columns: list of column names
546
+ - projectMeta: project meta information - if provided
547
+ - columnsOptions: list of dicts with options for each column
548
+ - total: total number of rows
549
+ - options: table options with the following fields:
550
+ - isRowClickable: whether rows are clickable
551
+ - isCellClickable: whether cells are clickable
552
+ - fixColumns: number of fixed columns
553
+ - isRadio: whether radio button selection mode is enabled
554
+ - isRowSelectable: whether multiple row selection is enabled
555
+ - maxSelectedRows: maximum number of rows that can be selected
556
+ - searchPosition: position of the search input ("left" or "right")
557
+ - pageSize: number of rows per page
558
+ - showHeader: whether to show table header
559
+ - selectionChangedHandled: whether selection changed event listener is set
560
+
347
561
  :param active_page: Specifies the size of the data to be exported. If True - returns only the active page of the table
348
562
  :type active_page: Optional[bool]
349
563
  :return: Table data with current options
@@ -373,17 +587,25 @@ class FastTable(Widget):
373
587
  :rtype: pd.DataFrame
374
588
  """
375
589
  if active_page is True:
376
- temp_parsed_data = [d["items"] for d in self._parsed_active_data["data"]]
590
+ # Return sliced data directly from source to preserve None/NaN values
591
+ packed_data = self._sliced_data.copy()
592
+ # Reset column names to first level only
593
+ if isinstance(packed_data.columns, pd.MultiIndex):
594
+ packed_data.columns = packed_data.columns.get_level_values("first")
377
595
  else:
378
- temp_parsed_data = [d["items"] for d in self._parsed_source_data["data"]]
379
- packed_data = pd.DataFrame(data=temp_parsed_data, columns=self._columns_first_idx)
596
+ # Return source data directly to preserve None/NaN values
597
+ packed_data = self._source_data.copy()
598
+ # Reset column names to first level only
599
+ if isinstance(packed_data.columns, pd.MultiIndex):
600
+ packed_data.columns = packed_data.columns.get_level_values("first")
380
601
  return packed_data
381
602
 
382
603
  def clear_selection(self) -> None:
383
604
  """Clears the selection of the table."""
384
- StateJson()[self.widget_id]["selectedRow"] = None
605
+ StateJson()[self.widget_id]["selectedRows"] = []
385
606
  StateJson()[self.widget_id]["selectedCell"] = None
386
607
  StateJson().send_changes()
608
+ self._maybe_update_selected_row()
387
609
 
388
610
  def get_selected_row(self) -> ClickedRow:
389
611
  """Returns the selected row.
@@ -391,29 +613,111 @@ class FastTable(Widget):
391
613
  :return: Selected row
392
614
  :rtype: ClickedRow
393
615
  """
394
- row_data = StateJson()[self.widget_id]["selectedRow"]
395
- row_index = row_data["idx"]
396
- row = row_data["row"]
397
- if row_index is None or row is None:
616
+ if self._is_radio or self._is_selectable:
617
+ selected_rows = StateJson()[self.widget_id]["selectedRows"]
618
+ if selected_rows is None:
619
+ return None
620
+ if len(selected_rows) == 0:
621
+ return None
622
+ if len(selected_rows) > 1:
623
+ raise ValueError(
624
+ "Multiple rows selected. Use get_selected_rows() method to get all selected rows."
625
+ )
626
+ row = selected_rows[0]
627
+ row_index = row["idx"]
628
+ row = row.get("row", row.get("items", None))
629
+ if row_index is None or row is None:
630
+ return None
631
+ return self.ClickedRow(row, row_index)
632
+ return self.get_clicked_row()
633
+
634
+ def get_selected_rows(self) -> List[ClickedRow]:
635
+ if self._is_radio or self._is_selectable:
636
+ selected_rows = StateJson()[self.widget_id]["selectedRows"]
637
+ rows = []
638
+ for row in selected_rows:
639
+ row_index = row["idx"]
640
+ if row_index is None:
641
+ continue
642
+ # Get original data from source_data to preserve None/NaN values
643
+ try:
644
+ row_data = self._source_data.loc[row_index].values.tolist()
645
+ except (KeyError, IndexError):
646
+ continue
647
+ rows.append(self.ClickedRow(row_data, row_index))
648
+ return rows
649
+ return [self.get_clicked_row()]
650
+
651
+ def get_clicked_row(self) -> ClickedRow:
652
+ clicked_row = StateJson()[self.widget_id]["clickedRow"]
653
+ if clicked_row is None:
654
+ return None
655
+ row_index = clicked_row["idx"]
656
+ if row_index is None:
657
+ return None
658
+ # Get original data from source_data to preserve None/NaN values
659
+ try:
660
+ row = self._source_data.loc[row_index].values.tolist()
661
+ except (KeyError, IndexError):
398
662
  return None
399
663
  return self.ClickedRow(row, row_index)
400
664
 
401
- def get_selected_cell(self) -> ClickedCell:
665
+ def get_clicked_cell(self) -> ClickedCell:
402
666
  """Returns the selected cell.
403
667
 
404
668
  :return: Selected cell
405
669
  :rtype: ClickedCell
406
670
  """
407
671
  cell_data = StateJson()[self.widget_id]["selectedCell"]
672
+ if cell_data is None:
673
+ return None
408
674
  row_index = cell_data["idx"]
409
- row = cell_data["row"]
410
675
  column_index = cell_data["column"]
676
+ if column_index is None or row_index is None:
677
+ return None
411
678
  column_name = self._columns_first_idx[column_index]
412
- column_value = row[column_index]
413
- if column_index is None or row is None:
679
+ # Get original data from source_data to preserve None/NaN values
680
+ try:
681
+ row = self._source_data.loc[row_index].values.tolist()
682
+ column_value = row[column_index]
683
+ except (KeyError, IndexError):
414
684
  return None
415
685
  return self.ClickedCell(row, column_index, row_index, column_name, column_value)
416
686
 
687
+ def get_selected_cell(self) -> ClickedCell:
688
+ """Alias for get_clicked_cell method.
689
+ Will be removed in future versions.
690
+ """
691
+ return self.get_clicked_cell()
692
+
693
+ def _maybe_update_selected_row(self) -> None:
694
+ if self._is_radio:
695
+ if self._rows_total != 0:
696
+ self.select_row(0)
697
+ else:
698
+ self._selected_rows = []
699
+ StateJson()[self.widget_id]["selectedRows"] = self._selected_rows
700
+ StateJson().send_changes()
701
+ return
702
+ if not self._selected_rows:
703
+ return
704
+ if self._rows_total == 0:
705
+ self._selected_rows = []
706
+ StateJson()[self.widget_id]["selectedRows"] = self._selected_rows
707
+ StateJson().send_changes()
708
+ return
709
+ if self._is_selectable:
710
+ updated_selected_rows = []
711
+ for row in self._parsed_source_data["data"]:
712
+ items = row.get("items", row.get("row", None))
713
+ if items is not None:
714
+ for selected_row in self._selected_rows:
715
+ if selected_row.get("row", selected_row.get("items", None)) == items:
716
+ updated_selected_rows.append(row)
717
+ self._selected_rows = updated_selected_rows
718
+ StateJson()[self.widget_id]["selectedRows"] = self._selected_rows
719
+ StateJson().send_changes()
720
+
417
721
  def insert_row(self, row: List, index: Optional[int] = -1) -> None:
418
722
  """Inserts a row into the table to the specified position.
419
723
 
@@ -424,8 +728,7 @@ class FastTable(Widget):
424
728
  """
425
729
  self._validate_table_sizes(row)
426
730
  self._validate_row_values_types(row)
427
- table_data = self._parsed_source_data
428
- index = len(table_data) if index > len(table_data) or index < 0 else index
731
+ index = len(self._source_data) if index > len(self._source_data) or index < 0 else index
429
732
 
430
733
  self._source_data = pd.concat(
431
734
  [
@@ -440,9 +743,28 @@ class FastTable(Widget):
440
743
  self._parsed_active_data,
441
744
  ) = self._prepare_working_data()
442
745
  self._rows_total = len(self._parsed_source_data["data"])
443
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
746
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
444
747
  DataJson()[self.widget_id]["total"] = self._rows_total
445
748
  DataJson().send_changes()
749
+ self._maybe_update_selected_row()
750
+
751
+ def add_rows(self, rows: List):
752
+ for row in rows:
753
+ self._validate_table_sizes(row)
754
+ self._validate_row_values_types(row)
755
+ self._source_data = pd.concat(
756
+ [self._source_data, pd.DataFrame(rows, columns=self._source_data.columns)]
757
+ ).reset_index(drop=True)
758
+ (
759
+ self._parsed_source_data,
760
+ self._sliced_data,
761
+ self._parsed_active_data,
762
+ ) = self._prepare_working_data()
763
+ self._rows_total = len(self._parsed_source_data["data"])
764
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
765
+ DataJson()[self.widget_id]["total"] = self._rows_total
766
+ DataJson().send_changes()
767
+ self._maybe_update_selected_row()
446
768
 
447
769
  def pop_row(self, index: Optional[int] = -1) -> List:
448
770
  """Removes a row from the table at the specified position and returns it.
@@ -467,9 +789,9 @@ class FastTable(Widget):
467
789
  self._parsed_active_data,
468
790
  ) = self._prepare_working_data()
469
791
  self._rows_total = len(self._parsed_source_data["data"])
470
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
792
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
471
793
  DataJson()[self.widget_id]["total"] = self._rows_total
472
- DataJson().send_changes()
794
+ self._maybe_update_selected_row()
473
795
  return popped_row
474
796
 
475
797
  def clear(self) -> None:
@@ -479,9 +801,10 @@ class FastTable(Widget):
479
801
  self._sliced_data = pd.DataFrame(columns=self._columns_first_idx)
480
802
  self._parsed_active_data = {"data": [], "columns": []}
481
803
  self._rows_total = 0
482
- DataJson()[self.widget_id]["data"] = []
804
+ DataJson()[self.widget_id]["data"] = {}
483
805
  DataJson()[self.widget_id]["total"] = 0
484
806
  DataJson().send_changes()
807
+ self._maybe_update_selected_row()
485
808
 
486
809
  def row_click(self, func: Callable[[ClickedRow], Any]) -> Callable[[], None]:
487
810
  """Decorator for function that handles row click event.
@@ -495,8 +818,8 @@ class FastTable(Widget):
495
818
  server = self._sly_app.get_server()
496
819
 
497
820
  self._row_click_handled = True
498
- self._clickable_rows = True
499
- DataJson()[self.widget_id]["options"]["isRowClickable"] = self._clickable_rows
821
+ self._is_row_clickable = True
822
+ DataJson()[self.widget_id]["options"]["isRowClickable"] = self._is_row_clickable
500
823
  DataJson().send_changes()
501
824
 
502
825
  if self._cell_click_handled is True:
@@ -528,8 +851,8 @@ class FastTable(Widget):
528
851
  server = self._sly_app.get_server()
529
852
 
530
853
  self._cell_click_handled = True
531
- self._clickable_cells = True
532
- DataJson()[self.widget_id]["options"]["isCellClickable"] = self._clickable_cells
854
+ self._is_cell_clickable = True
855
+ DataJson()[self.widget_id]["options"]["isCellClickable"] = self._is_cell_clickable
533
856
  DataJson().send_changes()
534
857
 
535
858
  if self._row_click_handled is True:
@@ -549,7 +872,44 @@ class FastTable(Widget):
549
872
 
550
873
  return _click
551
874
 
552
- def search(self, search_value: str) -> pd.DataFrame:
875
+ def _default_filter_function(self, data: pd.DataFrame, filter_value: Any) -> pd.DataFrame:
876
+ return data
877
+
878
+ def _filter_table_data(self, data: pd.DataFrame) -> pd.DataFrame:
879
+ """Filter source data using a self._filter_function as filter function.
880
+ To apply a custom filter function, use the set_filter method.
881
+
882
+ :return: Filtered data
883
+ :rtype: pd.DataFrame
884
+ """
885
+ filtered_data = self._filter_function(data, self._filter_value)
886
+ return filtered_data
887
+
888
+ def _filter(self, filter_value: Any) -> pd.DataFrame:
889
+ filtered_data = self._source_data.copy()
890
+ if filter_value is None:
891
+ return filtered_data
892
+ if self._filter_value != filter_value:
893
+ self._active_page = 1
894
+ StateJson()[self.widget_id]["page"] = self._active_page
895
+ StateJson().send_changes()
896
+ self._filter_value = filter_value
897
+ filtered_data = self._filter_table_data(filtered_data)
898
+ return filtered_data
899
+
900
+ def filter(self, filter_value) -> None:
901
+ self._filter_value = filter_value
902
+ self._refresh()
903
+
904
+ def _default_search_function(self, data: pd.DataFrame, search_value: str) -> pd.DataFrame:
905
+ # Use map() for pandas >= 2.1.0, fallback to applymap() for older versions
906
+ if hasattr(pd.DataFrame, "map"):
907
+ data = data[data.map(lambda x: search_value in str(x)).any(axis=1)]
908
+ else:
909
+ data = data[data.applymap(lambda x: search_value in str(x)).any(axis=1)]
910
+ return data
911
+
912
+ def _search(self, search_value: str) -> pd.DataFrame:
553
913
  """Search source data for search value.
554
914
 
555
915
  :param search_value: Search value
@@ -557,65 +917,141 @@ class FastTable(Widget):
557
917
  :return: Filtered data
558
918
  :rtype: pd.DataFrame
559
919
  """
560
- filtered_data = self._source_data.copy()
561
- if search_value == "":
562
- return filtered_data
920
+ # Use filtered_data if available, otherwise use source_data directly
921
+ if self._filtered_data is not None:
922
+ filtered_data = self._filtered_data.copy()
563
923
  else:
564
- if self._search_str != search_value:
565
- self._active_page = 1
566
- StateJson()[self.widget_id]["page"] = self._active_page
567
- StateJson().send_changes()
568
- filtered_data = filtered_data[
569
- filtered_data.applymap(lambda x: search_value in str(x)).any(axis=1)
570
- ]
924
+ filtered_data = self._source_data.copy()
925
+
926
+ if search_value == "":
571
927
  self._search_str = search_value
928
+ return filtered_data
929
+ if self._search_str != search_value:
930
+ self._active_page = 1
931
+ StateJson()[self.widget_id]["page"] = self._active_page
932
+ StateJson().send_changes()
933
+ filtered_data = self._search_function(filtered_data, search_value)
934
+ self._search_str = search_value
572
935
  return filtered_data
573
936
 
937
+ def search(self, search_value: str) -> None:
938
+ StateJson()[self.widget_id]["search"] = search_value
939
+ StateJson().send_changes()
940
+ self._refresh()
941
+
942
+ def _default_sort_function(
943
+ self,
944
+ data: pd.DataFrame,
945
+ column_idx: Optional[int],
946
+ order: Optional[Literal["asc", "desc"]],
947
+ ) -> pd.DataFrame:
948
+ if order == "asc":
949
+ ascending = True
950
+ else:
951
+ ascending = False
952
+ try:
953
+ column = data.columns[column_idx]
954
+ # Try to convert to numeric for proper sorting
955
+ numeric_column = pd.to_numeric(data[column], errors="coerce")
956
+
957
+ # Check if column contains numeric data (has at least one non-NaN numeric value)
958
+ if numeric_column.notna().sum() > 0:
959
+ # Create temporary column for sorting
960
+ data_copy = data.copy()
961
+ data_copy["_sort_key"] = numeric_column
962
+ # Sort by numeric values with NaN at the end
963
+ data_copy = data_copy.sort_values(
964
+ by="_sort_key", ascending=ascending, na_position="last"
965
+ )
966
+ # Remove temporary column and return original data in sorted order
967
+ data = data.loc[data_copy.index]
968
+ else:
969
+ # Sort as strings with NaN values at the end
970
+ data = data.sort_values(by=column, ascending=ascending, na_position="last")
971
+ except IndexError as e:
972
+ e.args = (
973
+ f"Sorting by column idx = {column_idx} is not possible, your table has only {len(data.columns)} columns with idx from 0 to {len(data.columns) - 1}",
974
+ )
975
+ raise e
976
+ return data
977
+
574
978
  def sort(
575
- self, column_idx: Optional[int] = None, order: Optional[Literal["asc", "desc"]] = None
979
+ self,
980
+ column_idx: Optional[int] = None,
981
+ order: Optional[Literal["asc", "desc"]] = None,
982
+ reset: bool = False,
576
983
  ) -> None:
577
984
  """Sorts table data by column index and order.
578
985
 
579
- :param column_idx: Index of the column to sort by
986
+ :param column_idx: Index of the column to sort by. If None, keeps current column (unless reset=True).
580
987
  :type column_idx: Optional[int]
581
- :param order: Sorting order
988
+ :param order: Sorting order. If None, keeps current order (unless reset=True).
582
989
  :type order: Optional[Literal["asc", "desc"]]
990
+ :param reset: If True, clears sorting completely. Default is False.
991
+ :type reset: bool
992
+
993
+ :Usage example:
994
+
995
+ .. code-block:: python
996
+ # Sorting examples
997
+ sort(column_idx=0, order="asc") # sort by column 0 ascending
998
+ sort(column_idx=1) # sort by column 1, keep current order
999
+ sort(order="desc") # keep current column, change order to descending
1000
+ sort(reset=True) # clear sorting completely
583
1001
  """
584
- self._sort_column_idx = column_idx
585
- self._sort_order = order
1002
+ # If reset=True, clear sorting completely
1003
+ if reset:
1004
+ self._sort_column_idx = None
1005
+ self._sort_order = None
1006
+ else:
1007
+ # Preserve current values if new ones are not provided
1008
+ if column_idx is not None:
1009
+ self._sort_column_idx = column_idx
1010
+ # else: keep current self._sort_column_idx
1011
+
1012
+ if order is not None:
1013
+ self._sort_order = order
1014
+ # else: keep current self._sort_order
1015
+
586
1016
  self._validate_sort_attrs()
587
- if self._sort_column_idx is not None:
588
- StateJson()[self.widget_id]["sort"]["column"] = self._sort_column_idx
589
- if self._sort_order is not None:
590
- StateJson()[self.widget_id]["sort"]["order"] = self._sort_order
591
- self._filtered_data = self.search(self._search_str)
592
- self._rows_total = len(self._filtered_data)
593
- self._sorted_data = self._sort_table_data(self._filtered_data)
1017
+
1018
+ # Always update StateJson with current values (including None)
1019
+ StateJson()[self.widget_id]["sort"]["column"] = self._sort_column_idx
1020
+ StateJson()[self.widget_id]["sort"]["order"] = self._sort_order
1021
+
1022
+ # Apply filter, search, sort pipeline
1023
+ self._filtered_data = self._filter(self._filter_value)
1024
+ self._searched_data = self._search(self._search_str)
1025
+ self._rows_total = len(self._searched_data)
1026
+ self._sorted_data = self._sort_table_data(self._searched_data)
594
1027
  self._sliced_data = self._slice_table_data(self._sorted_data, actual_page=self._active_page)
595
1028
  self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
596
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
1029
+
1030
+ # Update DataJson with sorted and paginated data
1031
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
597
1032
  DataJson()[self.widget_id]["total"] = self._rows_total
1033
+ self._maybe_update_selected_row()
598
1034
  StateJson().send_changes()
599
1035
 
600
1036
  def _prepare_json_data(self, data: dict, key: str):
601
1037
  if key in ("data", "columns"):
602
1038
  default_value = []
1039
+ elif key == "options":
1040
+ default_value = {}
603
1041
  else:
604
1042
  default_value = None
1043
+
605
1044
  source_data = data.get(key, default_value)
606
- if key == "data":
607
- source_data = self._sort_table_data(
608
- pd.DataFrame(data=source_data, columns=self._multi_idx_columns)
609
- )
610
- if key == "options":
611
- options = data.get(key, default_value)
612
- if options is not None:
613
- sort = options.get("sort", None)
614
- if sort is not None:
615
- column_idx = sort.get("columnIndex", None)
616
- if column_idx is not None:
617
- sort["column"] = sort.get("columnIndex")
618
- sort.pop("columnIndex")
1045
+
1046
+ # Normalize options format: convert "columnIndex" to "column"
1047
+ if key == "options" and source_data is not None:
1048
+ sort = source_data.get("sort", None)
1049
+ if sort is not None:
1050
+ column_idx = sort.get("columnIndex", None)
1051
+ if column_idx is not None:
1052
+ sort["column"] = column_idx
1053
+ sort.pop("columnIndex")
1054
+
619
1055
  return source_data
620
1056
 
621
1057
  def _validate_sort(
@@ -697,12 +1133,21 @@ class FastTable(Widget):
697
1133
  def _get_pandas_unpacked_data(self, data: pd.DataFrame) -> dict:
698
1134
  if not isinstance(data, pd.DataFrame):
699
1135
  raise TypeError("Cannot parse input data, please use Pandas Dataframe as input data")
700
- data = data.replace({np.nan: None})
701
- # data = data.astype(object).replace(np.nan, "-") # TODO: replace None later
1136
+
1137
+ # Create a copy for frontend display to avoid modifying source data
1138
+ display_data = data.copy()
1139
+ # Replace NaN and None with empty string only for display
1140
+ display_data = display_data.replace({np.nan: "", None: ""})
1141
+
1142
+ # Handle MultiIndex columns - extract only the first level
1143
+ if isinstance(display_data.columns, pd.MultiIndex):
1144
+ columns = display_data.columns.get_level_values("first").tolist()
1145
+ else:
1146
+ columns = display_data.columns.to_list()
702
1147
 
703
1148
  unpacked_data = {
704
- "columns": data.columns.to_list(),
705
- "data": data.values.tolist(),
1149
+ "columns": columns,
1150
+ "data": display_data.values.tolist(),
706
1151
  }
707
1152
  return unpacked_data
708
1153
 
@@ -746,28 +1191,29 @@ class FastTable(Widget):
746
1191
  data = data.iloc[start_idx:end_idx]
747
1192
  return data
748
1193
 
749
- def _sort_table_data(self, input_data: pd.DataFrame) -> pd.DataFrame:
1194
+ def _sort_table_data(
1195
+ self,
1196
+ input_data: pd.DataFrame,
1197
+ column_index: Optional[int] = None,
1198
+ sort_order: Optional[Literal["asc", "desc"]] = None,
1199
+ ) -> pd.DataFrame:
750
1200
  """
751
1201
  Apply sorting to received data
752
1202
 
753
1203
  """
754
- if self._sort_order is None or self._sort_column_idx is None:
1204
+ if column_index is None:
1205
+ column_index = self._sort_column_idx
1206
+ if sort_order is None:
1207
+ sort_order = self._sort_order
1208
+
1209
+ if sort_order is None or column_index is None:
755
1210
  return input_data # unsorted
756
1211
 
757
- if input_data is not None:
758
- if self._sort_order == "asc":
759
- ascending = True
760
- else:
761
- ascending = False
762
- data: pd.DataFrame = copy.deepcopy(input_data)
763
- try:
764
- data = data.sort_values(by=data.columns[self._sort_column_idx], ascending=ascending)
765
- except IndexError as e:
766
- e.args = (
767
- f"Sorting by column idx = {self._sort_column_idx} is not possible, your table has only {len(data.columns)} columns with idx from 0 to {len(data.columns) - 1}",
768
- )
769
- raise e
1212
+ data = copy.deepcopy(input_data)
1213
+ if input_data is None:
1214
+ return data
770
1215
 
1216
+ data = self._sort_function(data=input_data, column_idx=column_index, order=sort_order)
771
1217
  return data
772
1218
 
773
1219
  def _unpack_project_meta(self, project_meta: Union[ProjectMeta, dict]) -> dict:
@@ -857,19 +1303,160 @@ class FastTable(Widget):
857
1303
  self._sort_column_idx = StateJson()[self.widget_id]["sort"]["column"]
858
1304
  self._sort_order = StateJson()[self.widget_id]["sort"]["order"]
859
1305
  self._validate_sort_attrs()
860
- self._filtered_data = self.search(self._search_str)
861
- self._rows_total = len(self._filtered_data)
862
- self._sorted_data = self._sort_table_data(self._filtered_data)
1306
+ self._filtered_data = self._filter(self._filter_value)
1307
+ self._searched_data = self._search(self._search_str)
1308
+ self._rows_total = len(self._searched_data)
1309
+ self._sorted_data = self._sort_table_data(self._searched_data)
863
1310
 
864
1311
  increment = 0 if self._rows_total % self._page_size == 0 else 1
865
1312
  max_page = self._rows_total // self._page_size + increment
866
- if self._active_page > max_page: # active page is out of range (in case of the filtered data)
1313
+ if (
1314
+ self._active_page > max_page
1315
+ ): # active page is out of range (in case of the filtered data)
867
1316
  self._active_page = max_page
868
1317
  StateJson()[self.widget_id]["page"] = self._active_page
869
1318
 
870
1319
  self._sliced_data = self._slice_table_data(self._sorted_data, actual_page=self._active_page)
871
1320
  self._parsed_active_data = self._unpack_pandas_table_data(self._sliced_data)
872
- DataJson()[self.widget_id]["data"] = self._parsed_active_data["data"]
1321
+ DataJson()[self.widget_id]["data"] = list(self._parsed_active_data["data"])
873
1322
  DataJson()[self.widget_id]["total"] = self._rows_total
874
1323
  DataJson().send_changes()
875
1324
  StateJson().send_changes()
1325
+
1326
+ def selection_changed(self, func):
1327
+ """Decorator for function that handles selection change event.
1328
+
1329
+ :param func: Function that handles selection change event
1330
+ :type func: Callable[[], Any]
1331
+ :return: Decorated function
1332
+ :rtype: Callable[[], None]
1333
+ """
1334
+ selection_changed_route_path = self.get_route_path(FastTable.Routes.SELECTION_CHANGED)
1335
+ server = self._sly_app.get_server()
1336
+
1337
+ @server.post(selection_changed_route_path)
1338
+ def _selection_changed():
1339
+ if self._is_radio:
1340
+ selected_row = self.get_selected_row()
1341
+ func(selected_row)
1342
+ elif self._is_selectable:
1343
+ selected_rows = self.get_selected_rows()
1344
+ func(selected_rows)
1345
+
1346
+ self._selection_changed_handled = True
1347
+ DataJson()[self.widget_id]["selectionChangedHandled"] = True
1348
+ DataJson().send_changes()
1349
+ return _selection_changed
1350
+
1351
+ def select_row(self, idx: int):
1352
+ if not self._is_selectable and not self._is_radio:
1353
+ raise ValueError(
1354
+ "Table is not selectable. Set 'is_selectable' or 'is_radio' to True to use this method."
1355
+ )
1356
+ if idx < 0 or idx >= len(self._parsed_source_data["data"]):
1357
+ raise IndexError(
1358
+ f"Row index {idx} is out of range. Valid range is 0 to {len(self._parsed_source_data['data']) - 1}."
1359
+ )
1360
+ selected_row = self._parsed_source_data["data"][idx]
1361
+ self._selected_rows = [
1362
+ {"idx": idx, "row": selected_row.get("items", selected_row.get("row", None))}
1363
+ ]
1364
+ StateJson()[self.widget_id]["selectedRows"] = self._selected_rows
1365
+ page = idx // self._page_size + 1
1366
+ if self._active_page != page:
1367
+ self._active_page = page
1368
+ StateJson()[self.widget_id]["page"] = self._active_page
1369
+ self._refresh()
1370
+
1371
+ def select_rows(self, idxs: List[int]):
1372
+ if not self._is_selectable:
1373
+ raise ValueError(
1374
+ "Table is not selectable. Set 'is_selectable' to True to use this method."
1375
+ )
1376
+ selected_rows = [
1377
+ self._parsed_source_data["data"][idx]
1378
+ for idx in idxs
1379
+ if 0 <= idx < len(self._parsed_source_data["data"])
1380
+ ]
1381
+ self._selected_rows = [
1382
+ {"idx": row["idx"], "row": row.get("items", row.get("row", None))}
1383
+ for row in selected_rows
1384
+ ]
1385
+ StateJson()[self.widget_id]["selectedRows"] = self._selected_rows
1386
+ StateJson().send_changes()
1387
+
1388
+ def select_row_by_value(self, column, value: Any):
1389
+ """Selects a row by value in a specific column.
1390
+ The first column with the given name is used in case of duplicate column names.
1391
+
1392
+ :param column: Column name to filter by
1393
+ :type column: str
1394
+ :param value: Value to select row by
1395
+ :type value: Any
1396
+ """
1397
+ if not self._is_selectable and not self._is_radio:
1398
+ raise ValueError(
1399
+ "Table is not selectable. Set 'is_selectable' to True to use this method."
1400
+ )
1401
+ if column not in self._columns_first_idx:
1402
+ raise ValueError(f"Column '{column}' does not exist in the table.")
1403
+
1404
+ # Find the first column index with this name (in case of duplicates)
1405
+ column_idx = self._columns_first_idx.index(column)
1406
+ column_tuple = self._source_data.columns[column_idx]
1407
+
1408
+ # Use column tuple to access the specific column
1409
+ idx = self._source_data[self._source_data[column_tuple] == value].index.tolist()
1410
+ if not idx:
1411
+ raise ValueError(f"No rows found with {column} = {value}.")
1412
+ if len(idx) > 1:
1413
+ raise ValueError(
1414
+ f"Multiple rows found with {column} = {value}. Please use select_rows_by_value method."
1415
+ )
1416
+ self.select_row(idx[0])
1417
+
1418
+ def select_rows_by_value(self, column, values: List):
1419
+ """Selects rows by value in a specific column.
1420
+ The first column with the given name is used in case of duplicate column names.
1421
+
1422
+ :param column: Column name to filter by
1423
+ :type column: str
1424
+ :param values: List of values to select rows by
1425
+ :type values: List
1426
+ """
1427
+ if not self._is_selectable:
1428
+ raise ValueError(
1429
+ "Table is not selectable. Set 'is_selectable' to True to use this method."
1430
+ )
1431
+ if column not in self._columns_first_idx:
1432
+ raise ValueError(f"Column '{column}' does not exist in the table.")
1433
+
1434
+ # Find the first column index with this name (in case of duplicates)
1435
+ column_idx = self._columns_first_idx.index(column)
1436
+ column_tuple = self._source_data.columns[column_idx]
1437
+
1438
+ # Use column tuple to access the specific column
1439
+ idxs = self._source_data[self._source_data[column_tuple].isin(values)].index.tolist()
1440
+ self.select_rows(idxs)
1441
+
1442
+ def _read_custom_columns(self, columns: List[Union[str, tuple]]) -> None:
1443
+ if not columns:
1444
+ return
1445
+ self._columns = columns
1446
+ self._columns_options = self._columns_options or [{} for _ in columns]
1447
+ self._columns_data = []
1448
+ self._columns_first_idx = []
1449
+ for i, col in enumerate(columns):
1450
+ if isinstance(col, str):
1451
+ self._columns_first_idx.append(col)
1452
+ self._columns_data.append(self.ColumnData(name=col))
1453
+ elif isinstance(col, tuple):
1454
+ self._columns_first_idx.append(col[0])
1455
+ self._columns_data.append(
1456
+ self.ColumnData(name=col[0], is_widget=True, widget=col[1])
1457
+ )
1458
+ self._columns_options[i]["customCell"] = True
1459
+ else:
1460
+ raise TypeError(f"Column name must be a string or a tuple, got {type(col)}")
1461
+
1462
+ self._validate_sort_attrs()