nova-trame 1.0.0rc5__py3-none-any.whl → 1.1.0__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 nova-trame might be problematic. Click here for more details.

@@ -2,7 +2,7 @@
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
- from typing import Any, Dict, List, Optional
5
+ from typing import Any, Dict, List, Optional, Tuple
6
6
 
7
7
  from natsort import natsorted
8
8
  from pydantic import BaseModel, Field
@@ -13,6 +13,11 @@ class DataSelectorState(BaseModel, validate_assignment=True):
13
13
 
14
14
  directory: str = Field(default="")
15
15
  extensions: List[str] = Field(default=[])
16
+ search: str = Field(default="", title="Search")
17
+ # True: A->Z, False: Z->A, None: no order
18
+ sort_alpha: Optional[bool] = Field(default=None)
19
+ # True: Recent modifications first, False: Older modifications first, None: no order
20
+ sort_time: Optional[bool] = Field(default=None)
16
21
  subdirectory: str = Field(default="")
17
22
 
18
23
 
@@ -95,18 +100,25 @@ class DataSelectorModel:
95
100
  datafile_path = base_path / self.state.subdirectory
96
101
 
97
102
  for entry in os.scandir(datafile_path):
103
+ can_add = False
98
104
  if entry.is_file():
99
105
  if self.state.extensions:
100
106
  for extension in self.state.extensions:
101
107
  if entry.path.lower().endswith(extension):
102
- datafiles.append(entry.path)
108
+ can_add = True
103
109
  break
104
110
  else:
105
- datafiles.append(entry.path)
111
+ can_add = True
112
+
113
+ if self.state.search and self.state.search.lower() not in entry.path.lower():
114
+ can_add = False
115
+
116
+ if can_add:
117
+ datafiles.append((entry.path, entry.stat().st_mtime))
106
118
  except OSError:
107
119
  pass
108
120
 
109
- return natsorted(datafiles)
121
+ return self.sort_datafiles(datafiles)
110
122
 
111
123
  def get_datafiles(self) -> List[str]:
112
124
  base_path = Path(self.state.directory)
@@ -115,3 +127,33 @@ class DataSelectorModel:
115
127
 
116
128
  def set_subdirectory(self, subdirectory_path: str) -> None:
117
129
  self.state.subdirectory = subdirectory_path
130
+
131
+ def sort_datafiles(self, files: List[Tuple[str, float]]) -> List[str]:
132
+ if self.state.sort_alpha is not None:
133
+ files = natsorted(files, key=lambda x: x[0].lower(), reverse=not self.state.sort_alpha)
134
+ elif self.state.sort_time is not None:
135
+ files = sorted(files, key=lambda x: x[1], reverse=self.state.sort_time)
136
+
137
+ return [file[0] for file in files]
138
+
139
+ def toggle_alpha_sort(self) -> None:
140
+ # Reset the time sort since we've changed alpha sort more recently
141
+ self.state.sort_time = None
142
+
143
+ if self.state.sort_alpha is None:
144
+ self.state.sort_alpha = True
145
+ elif self.state.sort_alpha:
146
+ self.state.sort_alpha = False
147
+ else:
148
+ self.state.sort_alpha = None
149
+
150
+ def toggle_time_sort(self) -> None:
151
+ # Reset the alpha sort since we've changed time sort more recently
152
+ self.state.sort_alpha = None
153
+
154
+ if self.state.sort_time is None:
155
+ self.state.sort_time = True
156
+ elif self.state.sort_time:
157
+ self.state.sort_time = False
158
+ else:
159
+ self.state.sort_time = None
@@ -11,7 +11,7 @@ from trame_server.core import State
11
11
 
12
12
  from nova.mvvm._internal.utils import rgetdictvalue
13
13
  from nova.mvvm.trame_binding import TrameBinding
14
- from nova.trame._internal.utils import get_state_param, set_state_param
14
+ from nova.trame._internal.utils import get_state_name, get_state_param, set_state_param
15
15
  from nova.trame.model.data_selector import DataSelectorModel, DataSelectorState
16
16
  from nova.trame.view.layouts import GridLayout, HBoxLayout, VBoxLayout
17
17
  from nova.trame.view_model.data_selector import DataSelectorViewModel
@@ -28,6 +28,7 @@ class DataSelector(datagrid.VGrid):
28
28
  self,
29
29
  v_model: Union[str, Tuple],
30
30
  directory: Union[str, Tuple],
31
+ clear_selection_on_directory_change: Union[bool, Tuple] = True,
31
32
  extensions: Union[List[str], Tuple, None] = None,
32
33
  prefix: Union[str, Tuple] = "",
33
34
  subdirectory: Union[str, Tuple] = "",
@@ -45,6 +46,8 @@ class DataSelector(datagrid.VGrid):
45
46
  directory : Union[str, Tuple]
46
47
  The top-level folder to expose to users. Only contents of this directory and its children will be exposed to
47
48
  users.
49
+ clear_selection_on_directory_change: Union[bool, Tuple], optional
50
+ Whether or not to clear the selected files when the directory is changed.
48
51
  extensions : Union[List[str], Tuple], optional
49
52
  A list of file extensions to restrict selection to. If unset, then all files will be shown.
50
53
  prefix : Union[str, Tuple], optional
@@ -96,6 +99,7 @@ class DataSelector(datagrid.VGrid):
96
99
  else:
97
100
  self._v_model_name_in_state = v_model[0].split(".")[0]
98
101
 
102
+ self._clear_selection = clear_selection_on_directory_change
99
103
  self._directory = directory
100
104
  self._last_directory = get_state_param(self.state, self._directory)
101
105
  self._extensions = extensions if extensions is not None else []
@@ -129,6 +133,8 @@ class DataSelector(datagrid.VGrid):
129
133
  return get_server(None, client_type="vue3").state
130
134
 
131
135
  def create_ui(self, *args: Any, **kwargs: Any) -> None:
136
+ show_directories = isinstance(self._subdirectory, tuple) or not self._subdirectory
137
+
132
138
  with VBoxLayout(classes="nova-data-selector", stretch=True) as self._layout:
133
139
  with HBoxLayout(valign="center"):
134
140
  self._layout.filter = html.Div(classes="flex-1-1")
@@ -139,7 +145,7 @@ class DataSelector(datagrid.VGrid):
139
145
  vuetify.VTooltip("Refresh Contents", activator="parent")
140
146
 
141
147
  with GridLayout(columns=2, stretch=True):
142
- if isinstance(self._subdirectory, tuple) or not self._subdirectory:
148
+ if show_directories:
143
149
  with VBoxLayout(stretch=True):
144
150
  vuetify.VListSubheader("Available Directories", classes="flex-0-1 justify-center px-0")
145
151
  vuetify.VTreeview(
@@ -155,51 +161,113 @@ class DataSelector(datagrid.VGrid):
155
161
  )
156
162
  vuetify.VListItem("No directories found", classes="flex-0-1 text-center", v_else=True)
157
163
 
158
- if "columns" in kwargs:
159
- columns = kwargs.pop("columns")
160
- else:
161
- columns = (
162
- "[{"
163
- " cellTemplate: (createElement, props) =>"
164
- f" window.grid_manager.get('{self._revogrid_id}').cellTemplate(createElement, props),"
165
- " columnTemplate: (createElement) =>"
166
- f" window.grid_manager.get('{self._revogrid_id}').columnTemplate(createElement),"
167
- " name: 'Available Datafiles',"
168
- " prop: 'title',"
169
- "}]",
170
- )
164
+ with VBoxLayout(column_span=1 if show_directories else 2, stretch=True):
165
+ with VBoxLayout(classes="mx-2", gap="0.5em"):
166
+ with HBoxLayout(gap="0.25em", valign="center"):
167
+ if isinstance(self._extensions, tuple):
168
+ extensions_name = f"{get_state_name(self._extensions[0])}.extensions"
169
+ else:
170
+ extensions_name = f"{self._state_name}.extensions"
171
+
172
+ InputField(v_model=f"{self._state_name}.search")
173
+ with vuetify.VBtn(classes="icon-btn", icon=True, click=self._vm.toggle_alpha_sort):
174
+ vuetify.VTooltip(
175
+ "Sorting A->Z",
176
+ activator="parent",
177
+ v_if=f"{self._state_name}.sort_alpha === true",
178
+ )
179
+ vuetify.VTooltip(
180
+ "Sorting Z->A",
181
+ activator="parent",
182
+ v_else_if=f"{self._state_name}.sort_alpha === false",
183
+ )
184
+ vuetify.VTooltip("Click to sort alphanumerically", activator="parent", v_else=True)
185
+
186
+ vuetify.VIcon(
187
+ "mdi-sort-alphabetical-ascending",
188
+ size=16,
189
+ v_if=f"{self._state_name}.sort_alpha === true",
190
+ )
191
+ vuetify.VIcon(
192
+ "mdi-sort-alphabetical-descending",
193
+ size=16,
194
+ v_else_if=f"{self._state_name}.sort_alpha === false",
195
+ )
196
+ vuetify.VIcon("mdi-order-alphabetical-ascending", size=16, v_else=True)
197
+ with vuetify.VBtn(classes="icon-btn", icon=True, click=self._vm.toggle_time_sort):
198
+ vuetify.VTooltip(
199
+ "Newest modification times first",
200
+ activator="parent",
201
+ v_if=f"{self._state_name}.sort_time === true",
202
+ )
203
+ vuetify.VTooltip(
204
+ "Oldest modification times first",
205
+ activator="parent",
206
+ v_else_if=f"{self._state_name}.sort_time === false",
207
+ )
208
+ vuetify.VTooltip("Click to sort by modification times", activator="parent", v_else=True)
209
+
210
+ vuetify.VIcon(
211
+ "mdi-sort-clock-ascending",
212
+ size=16,
213
+ v_if=f"{self._state_name}.sort_time === true",
214
+ )
215
+ vuetify.VIcon(
216
+ "mdi-sort-clock-descending",
217
+ size=16,
218
+ v_else_if=f"{self._state_name}.sort_time === false",
219
+ )
220
+ vuetify.VIcon("mdi-clock", size=16, v_else=True)
221
+
222
+ html.P(
223
+ f"Showing {{{{ {extensions_name}.join(',') }}}} files",
224
+ v_if=f"{extensions_name}.length > 0",
225
+ )
171
226
 
172
- super().__init__(
173
- v_model=self._v_model,
174
- can_focus=False,
175
- columns=columns,
176
- column_span=1 if isinstance(self._subdirectory, tuple) or not self._subdirectory else 2,
177
- frame_size=10,
178
- hide_attribution=True,
179
- id=self._revogrid_id,
180
- readonly=True,
181
- stretch=True,
182
- source=(self._datafiles_name,),
183
- theme="compact",
184
- **kwargs,
185
- )
186
- if self._label:
187
- self.label = self._label
188
- if "update_modelValue" not in kwargs:
189
- self.update_modelValue = self._flush_state
190
-
191
- # Sets up some JavaScript event handlers when the component is mounted.
192
- with self:
193
- client.ClientTriggers(
194
- mounted=(
195
- "window.grid_manager.add("
196
- f" '{self._revogrid_id}',"
197
- f" '{self._v_model}',"
198
- f" '{self._datafiles_name}',"
199
- f" '{self._v_model_name_in_state}'"
200
- ")"
227
+ if "columns" in kwargs:
228
+ columns = kwargs.pop("columns")
229
+ else:
230
+ columns = (
231
+ "[{"
232
+ " cellTemplate: (createElement, props) =>"
233
+ f" window.grid_manager.get('{self._revogrid_id}').cellTemplate(createElement, props),"
234
+ " columnTemplate: (createElement) =>"
235
+ f" window.grid_manager.get('{self._revogrid_id}').columnTemplate(createElement),"
236
+ " name: 'Available Datafiles',"
237
+ " prop: 'title',"
238
+ "}]",
201
239
  )
240
+
241
+ super().__init__(
242
+ v_model=self._v_model,
243
+ can_focus=False,
244
+ columns=columns,
245
+ frame_size=10,
246
+ hide_attribution=True,
247
+ id=self._revogrid_id,
248
+ readonly=True,
249
+ stretch=True,
250
+ source=(self._datafiles_name,),
251
+ theme="compact",
252
+ **kwargs,
202
253
  )
254
+ if self._label:
255
+ self.label = self._label
256
+ if "update_modelValue" not in kwargs:
257
+ self.update_modelValue = self._flush_state
258
+
259
+ # Sets up some JavaScript event handlers when the component is mounted.
260
+ with self:
261
+ client.ClientTriggers(
262
+ mounted=(
263
+ "window.grid_manager.add("
264
+ f" '{self._revogrid_id}',"
265
+ f" '{self._v_model}',"
266
+ f" '{self._datafiles_name}',"
267
+ f" '{self._v_model_name_in_state}'"
268
+ ")"
269
+ )
270
+ )
203
271
 
204
272
  with cast(
205
273
  vuetify.VSelect,
@@ -240,8 +308,9 @@ class DataSelector(datagrid.VGrid):
240
308
  self._vm.update_view(refresh_directories=True)
241
309
 
242
310
  def reset(self, _: Any = None) -> None:
243
- self._reset_state()
244
- self._reset_rv_grid()
311
+ if bool(get_state_param(self.state, self._clear_selection)):
312
+ self._reset_state()
313
+ self._reset_rv_grid()
245
314
 
246
315
  def set_subdirectory(self, subdirectory_path: str = "") -> None:
247
316
  set_state_param(self.state, self._subdirectory, subdirectory_path)
@@ -31,6 +31,7 @@ class NeutronDataSelector(DataSelector):
31
31
  self,
32
32
  v_model: Union[str, Tuple],
33
33
  allow_custom_directories: Union[bool, Tuple] = False,
34
+ clear_selection_on_experiment_change: Union[bool, Tuple] = True,
34
35
  data_source: Literal["filesystem", "oncat"] = "filesystem",
35
36
  facility: Union[str, Tuple] = "",
36
37
  instrument: Union[str, Tuple] = "",
@@ -52,6 +53,8 @@ class NeutronDataSelector(DataSelector):
52
53
  allow_custom_directories : Union[bool, Tuple], optional
53
54
  Whether or not to allow users to provide their own directories to search for datafiles in. Ignored if the
54
55
  facility parameter is set.
56
+ clear_selection_on_experiment_change: Union[bool, Tuple], optional
57
+ Whether or not to clear the selected files when the user changes the facility, instrument, or experiment.
55
58
  data_source : Literal["filesystem", "oncat"], optional
56
59
  The source from which to pull datafiles. Defaults to "filesystem". If using ONCat, you will need to set the
57
60
  following environment variables for local development: `ONCAT_CLIENT_ID` and `ONCAT_CLIENT_SECRET`. Note
@@ -91,7 +94,7 @@ class NeutronDataSelector(DataSelector):
91
94
  if data_source == "oncat" and subdirectory:
92
95
  warn("subdirectory will be ignored since data will be pulled from ONCat.", stacklevel=1)
93
96
 
94
- if isinstance(facility, str) and allow_custom_directories:
97
+ if isinstance(facility, str) and facility and allow_custom_directories:
95
98
  warn("allow_custom_directories will be ignored since the facility parameter is fixed.", stacklevel=1)
96
99
 
97
100
  self._facility = facility
@@ -119,6 +122,7 @@ class NeutronDataSelector(DataSelector):
119
122
  super().__init__(
120
123
  v_model,
121
124
  "",
125
+ clear_selection_on_directory_change=clear_selection_on_experiment_change,
122
126
  extensions=extensions,
123
127
  subdirectory=subdirectory if data_source == "filesystem" else "oncat",
124
128
  refresh_rate=refresh_rate,
@@ -319,3 +323,9 @@ class NeutronDataSelector(DataSelector):
319
323
  experiment=set_state_param(self.state, (self._selected_experiment_name,), experiment),
320
324
  )
321
325
  self._vm.reset()
326
+
327
+ def set_state(self, *args: Any, **kwargs: Any) -> None:
328
+ raise TypeError(
329
+ "The set_state method has been removed. Please use update_facility, update_instrument, and "
330
+ "update_experiment instead."
331
+ )
@@ -13,7 +13,7 @@ from warnings import warn
13
13
 
14
14
  import tornado
15
15
  from aiohttp import ClientSession, WSMsgType, web
16
- from matplotlib import get_data_path
16
+ from matplotlib import get_data_path, rcParams
17
17
  from matplotlib.backends.backend_webagg import FigureManagerWebAgg, new_figure_manager_given_figure # type: ignore
18
18
  from matplotlib.figure import Figure
19
19
  from trame.app import get_server
@@ -56,7 +56,10 @@ class _MPLApplication(tornado.web.Application):
56
56
  self.supports_binary = message["value"]
57
57
  else:
58
58
  manager = self.application.manager # type: ignore
59
- manager.handle_json(message)
59
+ try:
60
+ manager.handle_json(message)
61
+ except Exception:
62
+ manager.refresh_all()
60
63
 
61
64
  def send_json(self, content: Any) -> None:
62
65
  set_event_loop(self.application.loop) # type: ignore
@@ -229,7 +232,6 @@ class MatplotlibFigure(matplotlib.Figure):
229
232
  else:
230
233
  kwargs["classes"] = "flex-1-1"
231
234
  if webagg:
232
- self._initial_resize = True
233
235
  if "id" in kwargs:
234
236
  kwargs.pop("id")
235
237
  warn("id parameter to MatplotlibFigure is ignored when webagg=True.", stacklevel=1)
@@ -264,7 +266,7 @@ class MatplotlibFigure(matplotlib.Figure):
264
266
  f"window.document.querySelectorAll('.nova-mpl').forEach((item) => {{ item.style.display = ''; }});"
265
267
  "window.trame.trigger("
266
268
  f" '{self._id}_resize',"
267
- f" [height, width, window.devicePixelRatio]"
269
+ f" [height, width]"
268
270
  ");"
269
271
  )
270
272
  self._resize_figure = client.JSEval(exec=self._trigger).exec
@@ -280,29 +282,21 @@ class MatplotlibFigure(matplotlib.Figure):
280
282
  ).exec
281
283
 
282
284
  @self._server.controller.trigger(f"{self._id}_resize")
283
- def resize_figure(height: int, width: int, device_pixel_ratio: float) -> None:
285
+ def resize_figure(height: int, width: int) -> None:
284
286
  if self._figure:
285
- # This is the browser standard assumption for DPI.
286
- dpi = 96
287
-
288
287
  if self._webagg:
289
288
  # Reserve space for the controls injected by webagg.
290
289
  height -= 48
291
290
  width -= 4
292
291
 
293
- if not self._initial_resize:
294
- # Handle device pixel ratio for retina displays
295
- dpi = int(dpi * device_pixel_ratio)
296
- height = int(height * device_pixel_ratio)
297
- width = int(width * device_pixel_ratio)
298
-
299
292
  if height <= 0 or width <= 0:
300
293
  return
301
294
 
302
295
  if self._webagg:
303
- self._initial_resize = False
304
-
305
- self._figure.set_dpi(dpi)
296
+ # Webagg does not respect the Figure object's DPI.
297
+ dpi = rcParams["figure.dpi"]
298
+ else:
299
+ dpi = self._figure.get_dpi()
306
300
  new_width = width / dpi
307
301
  new_height = height / dpi
308
302
  current_size = self._figure.get_size_inches()
@@ -74,7 +74,7 @@ class GridLayout(html.Div):
74
74
  classes = kwargs.pop("classes", [])
75
75
  if isinstance(classes, list):
76
76
  classes = " ".join(classes)
77
- classes += " d-grid"
77
+
78
78
  if stretch:
79
79
  if valign:
80
80
  warn("Ignoring valign parameter to GridLayout since stretch=True.", stacklevel=1)
@@ -83,6 +83,12 @@ class GridLayout(html.Div):
83
83
  else:
84
84
  classes += " flex-0-1"
85
85
 
86
+ v_show = kwargs.get("v_show", None)
87
+ if v_show:
88
+ classes = (f"{v_show} ? '{classes} d-grid' : '{classes}'",)
89
+ else:
90
+ classes += " d-grid"
91
+
86
92
  widget_style = self.get_root_styles(columns, height, width, halign, valign, gap)
87
93
  user_style = kwargs.pop("style", {})
88
94
 
@@ -63,12 +63,18 @@ class HBoxLayout(html.Div):
63
63
  classes = kwargs.pop("classes", [])
64
64
  if isinstance(classes, list):
65
65
  classes = " ".join(classes)
66
- classes += " d-flex flex-row"
66
+
67
67
  if stretch:
68
68
  classes += " flex-1-1 overflow-y-auto"
69
69
  else:
70
70
  classes += " flex-0-1"
71
71
 
72
+ v_show = kwargs.get("v_show", None)
73
+ if v_show:
74
+ classes = (f"{v_show} ? '{classes} d-flex flex-row' : '{classes}'",)
75
+ else:
76
+ classes += " d-flex flex-row"
77
+
72
78
  widget_style = self.get_root_styles(height, width, halign, valign, gap, vspace)
73
79
  user_style = kwargs.pop("style", {})
74
80
 
@@ -63,12 +63,18 @@ class VBoxLayout(html.Div):
63
63
  classes = kwargs.pop("classes", [])
64
64
  if isinstance(classes, list):
65
65
  classes = " ".join(classes)
66
- classes += " d-flex flex-column"
66
+
67
67
  if stretch:
68
68
  classes += " flex-1-1 overflow-y-auto"
69
69
  else:
70
70
  classes += " flex-0-1"
71
71
 
72
+ v_show = kwargs.get("v_show", None)
73
+ if v_show:
74
+ classes = (f"{v_show} ? '{classes} d-flex flex-column' : '{classes}'",)
75
+ else:
76
+ classes += " d-flex flex-column"
77
+
72
78
  widget_style = self.get_root_styles(height, width, halign, valign, gap, vspace)
73
79
  user_style = kwargs.pop("style", {})
74
80
 
@@ -12,6 +12,13 @@ html {
12
12
  box-shadow: none !important;
13
13
  }
14
14
 
15
+ .exit-button {
16
+ .v-btn__content,
17
+ .v-btn__prepend i {
18
+ font-size: 0.875rem !important;
19
+ }
20
+ }
21
+
15
22
  .mpl-message, .ui-dialog-titlebar {
16
23
  display: none !important;
17
24
  }
@@ -20,6 +27,10 @@ html {
20
27
  white-space: pre-wrap;
21
28
  }
22
29
 
30
+ .nova-pre-content:empty {
31
+ margin-bottom: 12px !important;
32
+ }
33
+
23
34
  .nova-data-selector {
24
35
  .v-list-group {
25
36
  --prepend-width: 12px !important;
@@ -47,11 +58,27 @@ html {
47
58
  cursor: pointer;
48
59
  margin-right: 0.25em;
49
60
  }
61
+
62
+ p {
63
+ line-height: 1;
64
+ margin-top: 1em;
65
+ white-space: normal;
66
+ }
67
+ }
68
+
69
+ .rgCell label {
70
+ cursor: pointer;
71
+ }
72
+
73
+ .rv-row-text {
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ white-space: nowrap;
50
77
  }
51
78
 
52
79
  .header-content {
53
80
  font-weight: 500;
54
- height: 40px;
81
+ height: fit-content;
55
82
  }
56
83
 
57
84
  .inner-content-table {
@@ -86,6 +113,8 @@ html {
86
113
 
87
114
  @media only screen and (max-width: 959px) {
88
115
  .d-grid {
116
+ /* This forces all grid rows to have the same height. If one doesn't want this to happen, then they should use the box layouts. :) */
117
+ grid-auto-rows: 1fr;
89
118
  grid-template-columns: repeat(1, 1fr) !important;
90
119
  }
91
120
 
@@ -183,7 +212,7 @@ html {
183
212
  font-size: 1rem;
184
213
  }
185
214
 
186
- .v-btn {
215
+ .v-btn:not(.exit-button) {
187
216
  min-width: 0px !important;
188
217
  padding: 5px 5px !important;
189
218
  box-shadow: none !important;
@@ -244,6 +273,14 @@ html {
244
273
  }
245
274
  }
246
275
  }
276
+
277
+ .v-treeview.hide-actions .v-list-item .v-list-item__prepend {
278
+ width: unset;
279
+ }
280
+
281
+ .nova-data-selector .icon-btn {
282
+ height: 28px;
283
+ }
247
284
  }
248
285
 
249
286
 
Binary file
@@ -4,11 +4,15 @@ class RevoGrid {
4
4
  this.modelKey = modelKey
5
5
  this.dataKey = dataKey
6
6
  this.stateKey = stateKey
7
+ this.lastSelection = null
8
+ this.shiftPressed = false
7
9
 
8
10
  this.grid = document.querySelector(`#${this.id}`)
9
11
  this.grid.addEventListener('viewportscroll', () => {
10
12
  this.updateCheckboxes()
11
13
  })
14
+
15
+ this.initShiftKeyListeners()
12
16
  }
13
17
 
14
18
  updateCheckboxes() {
@@ -66,11 +70,32 @@ class RevoGrid {
66
70
  const path = props.data[props.rowIndex].path
67
71
  const index = modelValue.indexOf(path)
68
72
 
69
- // We need to assign instead of modifying in place in order for the Trame watcher to pick up changes.
73
+ // I use _.set instead of modifying the modelValue in place in order for the Trame watcher to properly detect the change.
70
74
  if (e.target.checked && index < 0) {
71
- _.set(trameState, this.modelKey, _.concat(modelValue, path))
75
+ const newIndex = props.data.findIndex((entry) => entry.path === path)
76
+
77
+ if (this.shiftPressed && this.lastSelection !== null) {
78
+ let newPaths = []
79
+ // JavaScript doesn't allow a backwards step during slice, so we need to order the start/stop correctly.
80
+ if (this.lastSelection < newIndex) {
81
+ newPaths = props.data.slice(this.lastSelection, newIndex + 1)
82
+ } else {
83
+ newPaths = props.data.slice(newIndex, this.lastSelection)
84
+ }
85
+ // Exclude paths that are already selected to avoid duplicates.
86
+ newPaths = newPaths.map((entry) => entry.path).filter((path) => !modelValue.includes(path))
87
+
88
+ _.set(trameState, this.modelKey, _.concat(modelValue, newPaths))
89
+ } else {
90
+ _.set(trameState, this.modelKey, _.concat(modelValue, path))
91
+ }
92
+
93
+ this.lastSelection = newIndex
72
94
  } else if (index >= 0) {
73
95
  _.set(trameState, this.modelKey, modelValue.toSpliced(index, 1))
96
+
97
+ // Only allow range selection if the last action was to select a file.
98
+ this.lastSelection = null
74
99
  }
75
100
 
76
101
  // Update the UI
@@ -79,16 +104,18 @@ class RevoGrid {
79
104
  },
80
105
  })
81
106
 
82
- return createElement('label', undefined, inputVNode, props.model[props.prop])
107
+ const spanNode = createElement('span', {'class': 'cursor-pointer rv-row-text'}, props.model[props.prop])
108
+
109
+ return createElement('label', { 'title': props.model[props.prop] }, inputVNode, spanNode)
83
110
  }
84
111
 
85
112
  columnTemplate(createElement) {
113
+ const trameState = window.trame.state.state
114
+ const availableData = _.get(trameState, this.dataKey)
115
+
86
116
  const inputVNode = createElement('input', {
87
117
  type: 'checkbox',
88
118
  onChange: (e) => {
89
- const trameState = window.trame.state.state
90
- const availableData = _.get(trameState, this.dataKey)
91
-
92
119
  if (e.target.checked) {
93
120
  _.set(trameState, this.modelKey, availableData.map((item) => item.path))
94
121
  } else {
@@ -100,8 +127,26 @@ class RevoGrid {
100
127
  window.trame.state.dirty(this.stateKey)
101
128
  },
102
129
  })
130
+ const header = createElement('div', {'class': 'd-flex'}, inputVNode, 'Available Datafiles')
103
131
 
104
- return [inputVNode, 'Available Datafiles']
132
+ let controls = null
133
+ if (availableData.length < 1) {
134
+ controls = createElement('p', {}, 'No files found. Select a directory with files on the left.')
135
+ }
136
+
137
+ return createElement('div', {'class': 'd-flex flex-column'}, header, controls)
138
+ }
139
+
140
+ initShiftKeyListeners() {
141
+ window.document.addEventListener('keydown', (e) => {
142
+ this.shiftPressed = e.shiftKey
143
+ })
144
+
145
+ window.document.addEventListener('keyup', (e) => {
146
+ if (e.key === 'Shift') {
147
+ this.shiftPressed = false
148
+ }
149
+ })
105
150
  }
106
151
  }
107
152
 
@@ -1,9 +1,11 @@
1
1
  """Components used to control the lifecycle of a Themed Application."""
2
2
 
3
3
  import logging
4
+ from asyncio import sleep
4
5
  from typing import Any
5
6
 
6
7
  from trame.app import get_server
8
+ from trame.widgets import client
7
9
  from trame.widgets import vuetify3 as vuetify
8
10
 
9
11
  logger = logging.getLogger(__name__)
@@ -20,6 +22,10 @@ class ExitButton:
20
22
  self.server.state.nova_show_stop_jobs_on_exit_checkbox = False
21
23
  self.server.state.nova_running_jobs = []
22
24
  self.server.state.nova_show_exit_progress = False
25
+ # Note that window.close() will fail in many situations due to security concerns: https://developer.mozilla.org/en-US/docs/Web/API/Window/close
26
+ # This is simply a best effort to close the tab. My hope is that it will generally work when people are running
27
+ # applications through our dashboard since tabs are opened via target="_blank" links.
28
+ self.close_browser = client.JSEval(exec="window.close();").exec
23
29
  self.exit_application_callback = exit_callback
24
30
  self.job_status_callback = job_status_callback
25
31
  self.create_ui()
@@ -28,9 +34,10 @@ class ExitButton:
28
34
  with vuetify.VBtn(
29
35
  "Exit",
30
36
  prepend_icon="mdi-close-box",
31
- classes="mr-4 bg-error",
37
+ classes="exit-button mr-4 bg-secondary",
32
38
  id="shutdown_app_theme_button",
33
39
  color="white",
40
+ size="default",
34
41
  click=self.open_exit_dialog,
35
42
  ):
36
43
  with vuetify.VDialog(v_model="nova_show_exit_dialog", persistent="true"):
@@ -51,7 +58,7 @@ class ExitButton:
51
58
  with vuetify.VCardActions(v_if="!nova_show_exit_progress"):
52
59
  vuetify.VBtn(
53
60
  "Exit App",
54
- click=self.exit_application_callback,
61
+ click=self.exit_application,
55
62
  color="error",
56
63
  )
57
64
  vuetify.VBtn(
@@ -65,6 +72,13 @@ class ExitButton:
65
72
  )
66
73
  vuetify.VProgressCircular(indeterminate=True)
67
74
 
75
+ async def exit_application(self) -> None:
76
+ self.close_browser()
77
+
78
+ # sleep gives time for the Trame server to communicate the close request to the browser.
79
+ await sleep(0.1)
80
+ await self.exit_application_callback()
81
+
68
82
  async def open_exit_dialog(self) -> None:
69
83
  self.server.state.nova_show_exit_dialog = True
70
84
  await self.job_status_callback()
@@ -3,6 +3,7 @@
3
3
  import asyncio
4
4
  import json
5
5
  import logging
6
+ import os
6
7
  import sys
7
8
  from asyncio import create_task
8
9
  from functools import partial
@@ -210,6 +211,10 @@ class ThemedApp:
210
211
  -------
211
212
  `trame_client.ui.core.AbstractLayout <https://trame.readthedocs.io/en/latest/core.ui.html#trame_client.ui.core.AbstractLayout>`_
212
213
  """
214
+ # This detects if Poetry is running Python so that we can show links to NOVA resources during development.
215
+ # Poetry should not be used in production.
216
+ show_nova_resources = os.environ.get("VIRTUAL_ENV") is not None
217
+
213
218
  with VAppLayout(self.server, vuetify_config=self.vuetify_config) as layout:
214
219
  self.local_storage = LocalStorageManager(self.server.controller)
215
220
 
@@ -225,8 +230,32 @@ class ThemedApp:
225
230
  with vuetify.VAppBar() as toolbar:
226
231
  layout.toolbar = toolbar
227
232
 
228
- with vuetify.VAppBarTitle() as toolbar_title:
233
+ with vuetify.VAppBarTitle(classes="flex-0-1") as toolbar_title:
229
234
  layout.toolbar_title = toolbar_title
235
+
236
+ if show_nova_resources:
237
+ vuetify.VBtn(
238
+ "NOVA Examples",
239
+ classes="ml-4",
240
+ href="https://github.com/nova-model/nova-examples/",
241
+ __properties=["target"],
242
+ target="_blank",
243
+ )
244
+ html.Div("·", classes="mx-1")
245
+ vuetify.VBtn(
246
+ "NOVA Tutorial",
247
+ href="https://nova.ornl.gov/tutorial/",
248
+ __properties=["target"],
249
+ target="_blank",
250
+ )
251
+ html.Div("·", classes="mx-1")
252
+ vuetify.VBtn(
253
+ "NOVA Documentation",
254
+ href="https://nova-application-development.readthedocs.io/en/latest/",
255
+ __properties=["target"],
256
+ target="_blank",
257
+ )
258
+
230
259
  vuetify.VSpacer()
231
260
  with html.Div(classes="mr-2") as actions:
232
261
  layout.actions = actions
@@ -259,7 +288,7 @@ class ThemedApp:
259
288
 
260
289
  with vuetify.VMain(classes="align-stretch d-flex flex-column h-screen"):
261
290
  # [slot override example]
262
- layout.pre_content = vuetify.VSheet(classes="bg-background flex-0-1 mt-1 ")
291
+ layout.pre_content = vuetify.VSheet(classes="bg-background flex-0-1 mt-1 nova-pre-content ")
263
292
  # [slot override example complete]
264
293
  with vuetify.VContainer(classes="flex-1-1 overflow-hidden pt-0 pb-2", fluid=True):
265
294
  layout.content = vuetify.VCard(
@@ -56,7 +56,9 @@ class DataSelectorViewModel:
56
56
  self.expand_directory(paths)
57
57
 
58
58
  def on_state_updated(self, results: Dict[str, Any]) -> None:
59
- pass
59
+ for result in results.get("updated", []):
60
+ if result == "search":
61
+ self.update_view()
60
62
 
61
63
  def set_binding_parameters(self, **kwargs: Any) -> None:
62
64
  self.model.set_binding_parameters(**kwargs)
@@ -66,6 +68,14 @@ class DataSelectorViewModel:
66
68
  self.model.set_subdirectory(subdirectory_path)
67
69
  self.update_view()
68
70
 
71
+ def toggle_alpha_sort(self) -> None:
72
+ self.model.toggle_alpha_sort()
73
+ self.update_view()
74
+
75
+ def toggle_time_sort(self) -> None:
76
+ self.model.toggle_time_sort()
77
+ self.update_view()
78
+
69
79
  def transform_datafiles(self, datafiles: List[Any]) -> List[Dict[str, str]]:
70
80
  return [{"path": datafile, "title": os.path.basename(datafile)} for datafile in datafiles]
71
81
 
@@ -33,6 +33,8 @@ class NeutronDataSelectorViewModel(DataSelectorViewModel):
33
33
  case "custom_directory":
34
34
  self.reset()
35
35
  self.update_view()
36
+ case "search":
37
+ self.update_view()
36
38
 
37
39
  def transform_datafiles(self, datafiles: List[Any]) -> List[Dict[str, str]]:
38
40
  return [{"title": os.path.basename(datafile["path"]), **datafile} for datafile in datafiles]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nova-trame
3
- Version: 1.0.0rc5
3
+ Version: 1.1.0
4
4
  Summary: A Python Package for injecting curated themes and custom components into Trame applications
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -38,7 +38,7 @@ Description-Content-Type: text/markdown
38
38
  nova-trame
39
39
  ==========
40
40
 
41
- `nova-trame` is a Python package for streamlining development of Trame applications used in the NOVA project.
41
+ `nova-trame` is a Python package for streamlining development of Trame applications used in the NOVA framework.
42
42
 
43
43
  You can install this package directly with
44
44
 
@@ -46,6 +46,13 @@ You can install this package directly with
46
46
  pip install nova-trame
47
47
  ```
48
48
 
49
+ Once installed, you can check that it's working by running the widget gallery we use for visually testing components available through NOVA and Trame. We use [Poetry](https://python-poetry.org/) internally and recommend it for running the gallery.
50
+
51
+ ```commandline
52
+ poetry install
53
+ poetry run app
54
+ ```
55
+
49
56
  A user guide, examples, and a full API for this package can be found at [https://nova-application-development.readthedocs.io/en/stable/](https://nova-application-development.readthedocs.io/projects/nova-trame/en/stable/).
50
57
 
51
58
  Developers: please read [this document](DEVELOPMENT.md)
@@ -1,47 +1,47 @@
1
1
  nova/__init__.py,sha256=ED6jHcYiuYpr_0vjGz0zx2lrrmJT9sDJCzIljoDfmlM,65
2
2
  nova/trame/__init__.py,sha256=gFrAg1qva5PIqR5TjvPzAxLx103IKipJLqp3XXvrQL8,59
3
3
  nova/trame/_internal/utils.py,sha256=lTTJnfqbbIe21Tg2buf5MXqKUEUop7Va5PZgpWMzRkI,1381
4
- nova/trame/model/data_selector.py,sha256=hPPk1wiJc52jMXP_7XlEur38_vSi9tRUNKIa_gNmblc,4501
4
+ nova/trame/model/data_selector.py,sha256=_pGKvKoz9QheY4Zyi5dy9XjHuo75MoiN1J928J09dSo,6180
5
5
  nova/trame/model/ornl/analysis_data_selector.py,sha256=P7IEJdqCAUsEOCof4c2JantPXd9vz0EtvhryEKvscbw,5544
6
6
  nova/trame/model/ornl/neutron_data_selector.py,sha256=YMoNEpDKgjP_y18oYj-N9IjkxtqwHz9JYMlURQA4BCE,2148
7
7
  nova/trame/model/ornl/oncat_data_selector.py,sha256=3JEkWGMU-esWA9DUTglju9hEP9LyZ7EUXLj1yO5BIDs,4755
8
8
  nova/trame/model/remote_file_input.py,sha256=eAk7ZsFgNKcnpJ6KmOQDhiI6pPZpcrr1GMKkRLEWht8,4338
9
9
  nova/trame/view/components/__init__.py,sha256=60BeS69aOrFnkptjuD17rfPE1f4Z35iBH56TRmW5MW8,451
10
- nova/trame/view/components/data_selector.py,sha256=ByZ1RTyJRfmHNfN-nFi_Ihljo0yjbyfa_El-jHl9oyM,14907
10
+ nova/trame/view/components/data_selector.py,sha256=yQSxlotZX21pEZsKJKFEVQytN5PbjUCBMHPsROa4tjI,19127
11
11
  nova/trame/view/components/execution_buttons.py,sha256=Br6uAmE5bY67TTYc5ZTHECNJ_RJqKmv17HAKPpQtbeg,4576
12
12
  nova/trame/view/components/file_upload.py,sha256=WOaFXeNNwN0DYZJr-W6vWdBiTpr7m-lq3WKJaHmeMe8,4560
13
13
  nova/trame/view/components/input_field.py,sha256=xzCmNEoB4ljGx99-gGgTV0UwriwtS8ce22zPA4QneZw,17372
14
14
  nova/trame/view/components/ornl/__init__.py,sha256=HnxzzSsxw0vQSDCVFfWsAxx1n3HnU37LMuQkfiewmSU,90
15
- nova/trame/view/components/ornl/neutron_data_selector.py,sha256=duiPbROIYsU6h0oZP27wSce6BJuW1mdT8mGoxY05lyw,16542
15
+ nova/trame/view/components/ornl/neutron_data_selector.py,sha256=m_XAXdqTelUj6x1kj0kBq2dClVytTQqZ4a6fgNxGloA,17149
16
16
  nova/trame/view/components/progress_bar.py,sha256=zhbJwPy_HPQ8YL-ISN8sCRUQ7qY6qqo9wiV59BmvL8I,3038
17
17
  nova/trame/view/components/remote_file_input.py,sha256=mcz_bmI2rD8gdmIOKLhlzfj-XoWBwC99T9ZgQORaKqE,14674
18
18
  nova/trame/view/components/tool_outputs.py,sha256=IbYV4VjrkWAE354Bh5KH76SPsxGLIkOXChijS4-ce_Y,2408
19
19
  nova/trame/view/components/visualization/__init__.py,sha256=reqkkbhD5uSksHHlhVMy1qNUCwSekS5HlXk6wCREYxU,152
20
20
  nova/trame/view/components/visualization/interactive_2d_plot.py,sha256=z2s1janxAclpMEdDJk3z-CQ6r3KPNoR_SXPx9ppWnuQ,3481
21
- nova/trame/view/components/visualization/matplotlib_figure.py,sha256=GYfX7hlEWQ4Dghk8Z1dmUQr6jgktWirhOYb0drtonM4,16297
21
+ nova/trame/view/components/visualization/matplotlib_figure.py,sha256=8JgbF6elZC8EFZn-c2mLqGSyg0Lb4NLVAKJVSBb9-5g,16010
22
22
  nova/trame/view/layouts/__init__.py,sha256=cMrlB5YMUoK8EGB83b34UU0kPTVrH8AxsYvKRtpUNEc,141
23
- nova/trame/view/layouts/grid.py,sha256=DU5u5JTE0ulzCaJsEWyTenBH9lOQD7mtoC6RZXxDTPE,6110
24
- nova/trame/view/layouts/hbox.py,sha256=w6ow7Qzmq4slODz_9f7kEigCVPE2PhUmPODedYH34f4,3850
23
+ nova/trame/view/layouts/grid.py,sha256=3zm9rVa-cexqKCJXZPIGpfrZIbi7qaAGYSmUg7njTS4,6265
24
+ nova/trame/view/layouts/hbox.py,sha256=EOh-GT2rkt5qsvpBLjtrwdHlOA3hopcpFo7_TpxDOVs,4014
25
25
  nova/trame/view/layouts/utils.py,sha256=Hg34VQWTG3yHBsgNvmfatR4J-uL3cko7UxSJpT-h3JI,376
26
- nova/trame/view/layouts/vbox.py,sha256=Kkci79zDKF6qNH4HeDPYquJcixXx3BS63NVmS3FlOiw,3851
26
+ nova/trame/view/layouts/vbox.py,sha256=DrhBjDe2m4V42JV7Ma2YSPkJyaI6k74Yc9U1MgyuFfQ,4018
27
27
  nova/trame/view/theme/__init__.py,sha256=70_marDlTigIcPEOGiJb2JTs-8b2sGM5SlY7XBPtBDM,54
28
- nova/trame/view/theme/assets/core_style.scss,sha256=lZK8zghy4ExmNuFI-rfq7qt2S7bqObzM_MBviOlWP5c,4481
29
- nova/trame/view/theme/assets/favicon.png,sha256=Xbp1nUmhcBDeObjsebEbEAraPDZ_M163M_ZLtm5AbQc,1927
28
+ nova/trame/view/theme/assets/core_style.scss,sha256=IR3xekkhiJen1HVYOjbb2wB8JG80_7INzDtgosAdkOU,5290
29
+ nova/trame/view/theme/assets/favicon.png,sha256=F5r6SjZet8uARzESJgwRZGK_Q7pDguDG11hyOWIBOc4,11812
30
30
  nova/trame/view/theme/assets/js/delay_manager.js,sha256=BN4OL88QsyZG4XQ1sTorHpN1rwD4GnWoVKHvl5F5ydo,776
31
31
  nova/trame/view/theme/assets/js/lodash.min.js,sha256=KCyAYJ-fsqtp_HMwbjhy6IKjlA5lrVrtWt1JdMsC57k,73016
32
- nova/trame/view/theme/assets/js/revo_grid.js,sha256=fbuEWO8etw-xgo9tjJGjJXdd5wL8qpgabPmrnU6Jp8k,4081
32
+ nova/trame/view/theme/assets/js/revo_grid.js,sha256=KSUc8fOoX_BrOVtzIHcLscAiS4qLUclrbo1ndD2cj-A,6128
33
33
  nova/trame/view/theme/assets/vuetify_config.json,sha256=a0FSgpLYWGFlRGSMhMq61MyDFBEBwvz55G4qjkM08cs,5627
34
- nova/trame/view/theme/exit_button.py,sha256=Kqv1GVJZGrSsj6_JFjGU3vm3iNuMolLC2T1x2IsdmV0,3094
35
- nova/trame/view/theme/theme.py,sha256=Jeoi-qrokSO-dDYpEvicbqfwzalhhbG-4HplD8NFj6s,13269
34
+ nova/trame/view/theme/exit_button.py,sha256=GrgCMLytIrDTWAMtkuFv5JaIBpslFrmI5k2izklSqQs,3872
35
+ nova/trame/view/theme/theme.py,sha256=YecxrUw5I-7NDTyC8jrVdpb49U1TMzIbwO7jDid_jN0,14715
36
36
  nova/trame/view/utilities/local_storage.py,sha256=vD8f2VZIpxhIKjZwEaD7siiPCTZO4cw9AfhwdawwYLY,3218
37
- nova/trame/view_model/data_selector.py,sha256=jAtq5hpohQ6YiLBbgLJfNUzWZBpN2bjCG_c_FCJu2ns,3186
37
+ nova/trame/view_model/data_selector.py,sha256=Z_aLHcxatxneyjUBwrus79p_4YQcolmYohNktlraI9k,3507
38
38
  nova/trame/view_model/execution_buttons.py,sha256=MfKSp95D92EqpD48C15cBo6dLO0Yld4FeRZMJNxJf7Y,3551
39
- nova/trame/view_model/ornl/neutron_data_selector.py,sha256=PIKQyzcHpwu81DNk3d8AfgobDbxbdb9ppRLpEvdPgpw,1778
39
+ nova/trame/view_model/ornl/neutron_data_selector.py,sha256=PcnUUNN3S1i-Tc9G4ZZP6NPGcVhHtA0tx9cwlZ5g_5Y,1848
40
40
  nova/trame/view_model/progress_bar.py,sha256=6AUKHF3hfzbdsHqNEnmHRgDcBKY5TT8ywDx9S6ovnsc,2854
41
41
  nova/trame/view_model/remote_file_input.py,sha256=zWOflmCDJYYR_pacHphwzricV667GSRokh-mlxpBAOo,3646
42
42
  nova/trame/view_model/tool_outputs.py,sha256=ev6LY7fJ0H2xAJn9f5ww28c8Kpom2SYc2FbvFcoN4zg,829
43
- nova_trame-1.0.0rc5.dist-info/METADATA,sha256=E07ThBY0ho0JJE66zWp3IQJadp1e-nydsrAnUz93BTo,1798
44
- nova_trame-1.0.0rc5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
45
- nova_trame-1.0.0rc5.dist-info/entry_points.txt,sha256=J2AmeSwiTYZ4ZqHHp9HO6v4MaYQTTBPbNh6WtoqOT58,42
46
- nova_trame-1.0.0rc5.dist-info/licenses/LICENSE,sha256=Iu5QiDbwNbREg75iYaxIJ_V-zppuv4QFuBhAW-qiAlM,1061
47
- nova_trame-1.0.0rc5.dist-info/RECORD,,
43
+ nova_trame-1.1.0.dist-info/METADATA,sha256=zhUMQdY9OepYShQefWIQ_zwhLP197w9ecz0_G_QSDPU,2096
44
+ nova_trame-1.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
45
+ nova_trame-1.1.0.dist-info/entry_points.txt,sha256=J2AmeSwiTYZ4ZqHHp9HO6v4MaYQTTBPbNh6WtoqOT58,42
46
+ nova_trame-1.1.0.dist-info/licenses/LICENSE,sha256=Iu5QiDbwNbREg75iYaxIJ_V-zppuv4QFuBhAW-qiAlM,1061
47
+ nova_trame-1.1.0.dist-info/RECORD,,