nova-trame 0.8.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.
- nova/__init__.py +0 -0
- nova/trame/__init__.py +3 -0
- nova/trame/model/remote_file_input.py +98 -0
- nova/trame/view/components/__init__.py +4 -0
- nova/trame/view/components/input_field.py +273 -0
- nova/trame/view/components/remote_file_input.py +193 -0
- nova/trame/view/components/visualization/__init__.py +3 -0
- nova/trame/view/components/visualization/interactive_2d_plot.py +85 -0
- nova/trame/view/layouts/__init__.py +5 -0
- nova/trame/view/layouts/grid.py +148 -0
- nova/trame/view/layouts/hbox.py +79 -0
- nova/trame/view/layouts/vbox.py +79 -0
- nova/trame/view/theme/__init__.py +3 -0
- nova/trame/view/theme/assets/core_style.scss +30 -0
- nova/trame/view/theme/assets/favicon.png +0 -0
- nova/trame/view/theme/assets/vuetify_config.json +182 -0
- nova/trame/view/theme/theme.py +262 -0
- nova/trame/view/utilities/local_storage.py +102 -0
- nova/trame/view_model/remote_file_input.py +85 -0
- nova_trame-0.8.0.dist-info/LICENSE +21 -0
- nova_trame-0.8.0.dist-info/METADATA +40 -0
- nova_trame-0.8.0.dist-info/RECORD +24 -0
- nova_trame-0.8.0.dist-info/WHEEL +4 -0
- nova_trame-0.8.0.dist-info/entry_points.txt +3 -0
nova/__init__.py
ADDED
File without changes
|
nova/trame/__init__.py
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
"""Model state for RemoteFileInput."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from typing import Any, Union
|
5
|
+
|
6
|
+
|
7
|
+
class RemoteFileInputModel:
|
8
|
+
"""Manages interactions between RemoteFileInput and the file system."""
|
9
|
+
|
10
|
+
def __init__(self, allow_files: bool, allow_folders: bool, base_paths: list[str], extensions: list[str]) -> None:
|
11
|
+
"""Creates a new RemoteFileInputModel."""
|
12
|
+
self.allow_files = allow_files
|
13
|
+
self.allow_folders = allow_folders
|
14
|
+
self.base_paths = base_paths
|
15
|
+
self.extensions = extensions
|
16
|
+
|
17
|
+
def get_base_paths(self) -> list[dict[str, Any]]:
|
18
|
+
return [{"path": base_path, "directory": True} for base_path in self.base_paths]
|
19
|
+
|
20
|
+
def scan_current_path(self, current_path: str, showing_all_files: bool) -> tuple[list[dict[str, Any]], bool]:
|
21
|
+
failed = False
|
22
|
+
|
23
|
+
try:
|
24
|
+
if current_path and (not self.valid_subpath(current_path) or not os.path.exists(current_path)):
|
25
|
+
raise FileNotFoundError
|
26
|
+
|
27
|
+
files = [{"path": "..", "directory": True}]
|
28
|
+
|
29
|
+
if os.path.isdir(current_path):
|
30
|
+
scan_path = current_path
|
31
|
+
else:
|
32
|
+
scan_path = os.path.dirname(current_path)
|
33
|
+
|
34
|
+
for entry in os.scandir(scan_path):
|
35
|
+
if self.valid_entry(entry, showing_all_files):
|
36
|
+
files.append({"path": entry.name, "directory": entry.is_dir()})
|
37
|
+
except OSError:
|
38
|
+
files = self.get_base_paths()
|
39
|
+
failed = True
|
40
|
+
|
41
|
+
sorted_files = sorted(files, key=lambda entry: str(entry["path"]).lower())
|
42
|
+
|
43
|
+
return (sorted_files, failed)
|
44
|
+
|
45
|
+
def select_file(self, file: Union[dict[str, str], str], old_path: str, showing_base_paths: bool) -> str:
|
46
|
+
if file == "":
|
47
|
+
return ""
|
48
|
+
|
49
|
+
if isinstance(file, dict):
|
50
|
+
file = file["path"]
|
51
|
+
|
52
|
+
if not os.path.isdir(old_path):
|
53
|
+
# If the previous selection is a file, then we need to append to its parent directory
|
54
|
+
old_path = os.path.dirname(old_path)
|
55
|
+
|
56
|
+
if not showing_base_paths and file != "..":
|
57
|
+
return os.path.join(old_path, file)
|
58
|
+
elif not showing_base_paths:
|
59
|
+
if old_path in self.base_paths:
|
60
|
+
return ""
|
61
|
+
else:
|
62
|
+
return os.path.dirname(old_path)
|
63
|
+
else:
|
64
|
+
return file
|
65
|
+
|
66
|
+
def valid_entry(self, entry: os.DirEntry, showing_all_files: bool) -> bool:
|
67
|
+
if entry.is_dir():
|
68
|
+
return True
|
69
|
+
|
70
|
+
if not self.allow_files:
|
71
|
+
return False
|
72
|
+
|
73
|
+
return showing_all_files or not self.extensions or any(entry.name.endswith(ext) for ext in self.extensions)
|
74
|
+
|
75
|
+
def valid_selection(self, selection: str) -> bool:
|
76
|
+
if self.valid_subpath(selection):
|
77
|
+
if os.path.isdir(selection) and self.allow_folders:
|
78
|
+
return True
|
79
|
+
|
80
|
+
if os.path.isfile(selection) and self.allow_files:
|
81
|
+
return True
|
82
|
+
|
83
|
+
return False
|
84
|
+
|
85
|
+
def valid_subpath(self, subpath: str) -> bool:
|
86
|
+
if subpath == "":
|
87
|
+
return False
|
88
|
+
|
89
|
+
try:
|
90
|
+
real_path = os.path.realpath(subpath)
|
91
|
+
except TypeError:
|
92
|
+
return False
|
93
|
+
|
94
|
+
for base_path in self.base_paths:
|
95
|
+
if real_path.startswith(base_path):
|
96
|
+
return True
|
97
|
+
|
98
|
+
return False
|
@@ -0,0 +1,273 @@
|
|
1
|
+
"""View Implementation for InputField."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
from typing import Any, Dict
|
6
|
+
|
7
|
+
from mvvm_lib.pydantic_utils import get_field_info
|
8
|
+
from trame.app import get_server
|
9
|
+
from trame.widgets import client
|
10
|
+
from trame.widgets import vuetify3 as vuetify
|
11
|
+
from trame_client.widgets.core import AbstractElement
|
12
|
+
from trame_server.controller import Controller
|
13
|
+
from trame_server.state import State
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class InputField:
|
19
|
+
"""Factory class for generating Vuetify input components."""
|
20
|
+
|
21
|
+
@staticmethod
|
22
|
+
def create_boilerplate_properties(v_model: str | None) -> dict:
|
23
|
+
if not v_model:
|
24
|
+
return {}
|
25
|
+
object_name_in_state = v_model.split(".")[0]
|
26
|
+
field_info = None
|
27
|
+
try:
|
28
|
+
field_name = ".".join(v_model.split(".")[1:])
|
29
|
+
if "[" in field_name:
|
30
|
+
index_field_name = re.sub(r"\[.*?\]", "[0]", field_name)
|
31
|
+
field_info = get_field_info(index_field_name)
|
32
|
+
if "[" in field_name and "[index]" not in field_name:
|
33
|
+
field_info = None
|
34
|
+
logger.warning(
|
35
|
+
f"{v_model}: validation ignored. We currently only "
|
36
|
+
f"support single loop with index variable that should be called 'index'"
|
37
|
+
)
|
38
|
+
else:
|
39
|
+
field_info = get_field_info(field_name)
|
40
|
+
except Exception as _:
|
41
|
+
pass
|
42
|
+
label = ""
|
43
|
+
help_dict: dict = {}
|
44
|
+
placeholder = None
|
45
|
+
if field_info:
|
46
|
+
label = field_info.title
|
47
|
+
if field_info.examples and len(field_info.examples) > 0:
|
48
|
+
placeholder = field_info.examples[0]
|
49
|
+
help_dict = {"hint": field_info.description, "placeholder": placeholder}
|
50
|
+
|
51
|
+
args: Dict[str, Any] = {}
|
52
|
+
if v_model:
|
53
|
+
args |= {
|
54
|
+
"v_model": v_model,
|
55
|
+
"label": label,
|
56
|
+
"help": help_dict,
|
57
|
+
"update_modelValue": f"flushState('{object_name_in_state}')",
|
58
|
+
}
|
59
|
+
if field_info:
|
60
|
+
args |= {
|
61
|
+
"rules": (f"[(v) => trigger('validate_pydantic_field', ['{field_name}', v, index])]",),
|
62
|
+
}
|
63
|
+
return args
|
64
|
+
|
65
|
+
def __new__(
|
66
|
+
cls, v_model: str | None = None, required: bool = False, type: str = "text", **kwargs: Any
|
67
|
+
) -> AbstractElement:
|
68
|
+
"""Constructor for InputField.
|
69
|
+
|
70
|
+
Parameters
|
71
|
+
----------
|
72
|
+
required : bool
|
73
|
+
If true, the input will be visually marked as required and a required rule will be added to the end of the
|
74
|
+
rules list.
|
75
|
+
type : str
|
76
|
+
The type of input to create. This can be any of the following:
|
77
|
+
|
78
|
+
- autocomplete
|
79
|
+
- autoscroll (produces a textarea that automatically scrolls to the bottom as new content is added)
|
80
|
+
- checkbox
|
81
|
+
- combobox
|
82
|
+
- file
|
83
|
+
- input
|
84
|
+
- otp
|
85
|
+
- radio
|
86
|
+
- range-slider
|
87
|
+
- select
|
88
|
+
- slider
|
89
|
+
- switch
|
90
|
+
- textarea
|
91
|
+
|
92
|
+
Any other value will produce a text field with your type used as an HTML input type attribute.
|
93
|
+
|
94
|
+
Returns
|
95
|
+
-------
|
96
|
+
`trame_client.widgets.core.AbstractElement <https://trame.readthedocs.io/en/latest/core.widget.html#trame_client.widgets.core.AbstractElement>`_
|
97
|
+
The Vuetify input component.
|
98
|
+
"""
|
99
|
+
server = get_server(None, client_type="vue3")
|
100
|
+
|
101
|
+
kwargs = {**cls.create_boilerplate_properties(v_model), **kwargs}
|
102
|
+
|
103
|
+
if "__events" not in kwargs or kwargs["__events"] is None:
|
104
|
+
kwargs["__events"] = []
|
105
|
+
|
106
|
+
# This must be present before each input is created or change events won't be triggered.
|
107
|
+
if isinstance(kwargs["__events"], list):
|
108
|
+
if "change" not in kwargs["__events"]:
|
109
|
+
kwargs["__events"].append("change")
|
110
|
+
if "scroll" not in kwargs["__events"]:
|
111
|
+
kwargs["__events"].append("scroll")
|
112
|
+
|
113
|
+
match type:
|
114
|
+
case "autocomplete":
|
115
|
+
input = vuetify.VAutocomplete(**kwargs)
|
116
|
+
case "autoscroll":
|
117
|
+
input = vuetify.VTextarea(**kwargs)
|
118
|
+
cls._setup_autoscroll(server.state, input)
|
119
|
+
case "checkbox":
|
120
|
+
input = vuetify.VCheckbox(**kwargs)
|
121
|
+
case "combobox":
|
122
|
+
input = vuetify.VCombobox(**kwargs)
|
123
|
+
case "file":
|
124
|
+
input = vuetify.VFileInput(**kwargs)
|
125
|
+
case "input":
|
126
|
+
input = vuetify.VInput(**kwargs)
|
127
|
+
case "otp":
|
128
|
+
input = vuetify.VOtpInput(**kwargs)
|
129
|
+
case "radio":
|
130
|
+
input = vuetify.VRadioGroup(**kwargs)
|
131
|
+
case "range-slider":
|
132
|
+
input = vuetify.VRangeSlider(**kwargs)
|
133
|
+
case "select":
|
134
|
+
items = kwargs.pop("items", None)
|
135
|
+
if isinstance(items, str):
|
136
|
+
items = (items,)
|
137
|
+
|
138
|
+
input = vuetify.VSelect(items=items, **kwargs)
|
139
|
+
case "slider":
|
140
|
+
input = vuetify.VSlider(**kwargs)
|
141
|
+
case "switch":
|
142
|
+
input = vuetify.VSwitch(**kwargs)
|
143
|
+
case "textarea":
|
144
|
+
input = vuetify.VTextarea(**kwargs)
|
145
|
+
case _:
|
146
|
+
input = vuetify.VTextField(type=type, **kwargs)
|
147
|
+
|
148
|
+
cls._setup_help(input, **kwargs)
|
149
|
+
|
150
|
+
cls._check_rules(input)
|
151
|
+
if required:
|
152
|
+
cls._setup_required_label(input)
|
153
|
+
cls._setup_required_rule(input)
|
154
|
+
|
155
|
+
cls._setup_ref(input)
|
156
|
+
cls._setup_change_listener(server.controller, input)
|
157
|
+
|
158
|
+
return input
|
159
|
+
|
160
|
+
@staticmethod
|
161
|
+
def _check_rules(input: AbstractElement) -> None:
|
162
|
+
if "rules" in input._py_attr and input.rules and not isinstance(input.rules, tuple):
|
163
|
+
raise ValueError(f"Rules for '{input.label}' must be a tuple")
|
164
|
+
|
165
|
+
@staticmethod
|
166
|
+
def _setup_autoscroll(state: State, input: AbstractElement) -> None:
|
167
|
+
if input.v_model:
|
168
|
+
if "id" not in input._py_attr or input.id is None:
|
169
|
+
input.id = f"nova__{input._id}"
|
170
|
+
input.scroll = f"window.nova__autoscroll('{input.id}');"
|
171
|
+
|
172
|
+
with state:
|
173
|
+
if state["nova_scroll_position"] is None:
|
174
|
+
state["nova_scroll_position"] = {}
|
175
|
+
state.nova_scroll_position[input.id] = 0
|
176
|
+
|
177
|
+
autoscroll = client.JSEval(
|
178
|
+
exec=(
|
179
|
+
"if (window.nova__autoscroll !== undefined) {"
|
180
|
+
# If the autoscroll function already exists, call it.
|
181
|
+
" window.nova__autoscroll($event);"
|
182
|
+
"} else {"
|
183
|
+
# Save the JS so it can be called from outside of this script (ie during a scroll event).
|
184
|
+
" window.nova__autoscroll = function(id) {"
|
185
|
+
# Get the element in the browser by ID
|
186
|
+
" const element = window.document.querySelector(`#${id}`);"
|
187
|
+
# If the user is at the bottom of the textarea, then we should autoscroll.
|
188
|
+
" if (element.scrollTop === window.trame.state.state.nova_scroll_position[id]) {"
|
189
|
+
# Scroll to the bottom
|
190
|
+
" element.scrollTop = element.scrollHeight;"
|
191
|
+
# Save the new scroll position
|
192
|
+
" window.trame.state.state.nova_scroll_position[id] = element.scrollTop;"
|
193
|
+
" flushState('nova_scroll_position');"
|
194
|
+
# If the user has scrolled back to the bottom, then we should reenable scrolling.
|
195
|
+
" } else if (element.scrollTop === element.scrollHeight - element.clientHeight) {"
|
196
|
+
# Save the new scroll position
|
197
|
+
" window.trame.state.state.nova_scroll_position[id] = element.scrollTop;"
|
198
|
+
" flushState('nova_scroll_position');"
|
199
|
+
" }"
|
200
|
+
" };"
|
201
|
+
" window.nova__autoscroll($event);"
|
202
|
+
"}"
|
203
|
+
)
|
204
|
+
).exec
|
205
|
+
|
206
|
+
@state.change(input.v_model)
|
207
|
+
def _autoscroll(**kwargs: Any) -> None:
|
208
|
+
autoscroll(input.id)
|
209
|
+
|
210
|
+
@staticmethod
|
211
|
+
def _setup_help(_input: AbstractElement, **kwargs: Any) -> None:
|
212
|
+
help = kwargs.get("help", None)
|
213
|
+
if help and isinstance(help, dict):
|
214
|
+
_input.hint = help.get("hint", None)
|
215
|
+
_input.placeholder = help.get("placeholder", None)
|
216
|
+
|
217
|
+
@staticmethod
|
218
|
+
def _setup_required_label(input: AbstractElement) -> None:
|
219
|
+
if input.label:
|
220
|
+
input.label = f"{input.label}*"
|
221
|
+
else:
|
222
|
+
input.label = "*"
|
223
|
+
|
224
|
+
@staticmethod
|
225
|
+
def _setup_ref(input: AbstractElement) -> None:
|
226
|
+
if "ref" not in input._py_attr or input.ref is None:
|
227
|
+
input.ref = f"nova__{input._id}"
|
228
|
+
|
229
|
+
@staticmethod
|
230
|
+
def _setup_required_rule(input: AbstractElement) -> None:
|
231
|
+
required_rule = "(value) => value?.length > 0 || 'Field is required'"
|
232
|
+
if "rules" in input._py_attr and input.rules:
|
233
|
+
# Existing rules will be in format ("[rule1, rule2]",) and we need to append to this list
|
234
|
+
rule_end_index = input.rules[0].rindex("]")
|
235
|
+
input.rules = (f"{input.rules[0][:rule_end_index]}, {required_rule}{input.rules[0][rule_end_index:]}",)
|
236
|
+
else:
|
237
|
+
input.rules = (f"[{required_rule}]",)
|
238
|
+
|
239
|
+
@staticmethod
|
240
|
+
def _setup_change_listener(ctrl: Controller, input: AbstractElement) -> None:
|
241
|
+
base_handler = None
|
242
|
+
if "change" in input._py_attr and input.change is not None:
|
243
|
+
base_handler = input.change
|
244
|
+
|
245
|
+
# Iterate over all saved refs and perform validation if there is a value that can be validated.
|
246
|
+
change_handler = (
|
247
|
+
"Object.values(window.trame.refs).map("
|
248
|
+
" (ref) => typeof ref.validate === 'function' && ref.value ? ref.validate() : null"
|
249
|
+
");"
|
250
|
+
)
|
251
|
+
|
252
|
+
# We need to coerce the developer's change handler, which could be a string, callable, or tuple containing a
|
253
|
+
# callable, to a single string to be compatible with our change handler.
|
254
|
+
if callable(base_handler):
|
255
|
+
base_handler = (base_handler,)
|
256
|
+
if isinstance(base_handler, tuple):
|
257
|
+
|
258
|
+
@ctrl.trigger(f"{input.ref}__trigger")
|
259
|
+
def _(*args: str, **kwargs: Any) -> None:
|
260
|
+
base_handler[0](*args, **kwargs)
|
261
|
+
|
262
|
+
change_handler = (
|
263
|
+
"trigger("
|
264
|
+
f"'{input.ref}__trigger', "
|
265
|
+
f"{base_handler[1] if len(base_handler) > 1 else []}, "
|
266
|
+
f"{base_handler[2] if len(base_handler) > 2 else {} }"
|
267
|
+
f"); {change_handler}"
|
268
|
+
) # Call the developer's provided change method via a trigger, then call ours.
|
269
|
+
elif isinstance(base_handler, str):
|
270
|
+
# Call the developer's provided change JS expression, then call ours.
|
271
|
+
change_handler = f"{base_handler}; {change_handler}"
|
272
|
+
|
273
|
+
input.change = change_handler
|
@@ -0,0 +1,193 @@
|
|
1
|
+
"""View implementation for RemoteFileInput."""
|
2
|
+
|
3
|
+
from functools import partial
|
4
|
+
from typing import Any, Optional, cast
|
5
|
+
|
6
|
+
from mvvm_lib.trame_binding import TrameBinding
|
7
|
+
from trame.app import get_server
|
8
|
+
from trame.widgets import client, html
|
9
|
+
from trame.widgets import vuetify3 as vuetify
|
10
|
+
from trame_client.widgets.core import AbstractElement
|
11
|
+
|
12
|
+
from nova.trame.model.remote_file_input import RemoteFileInputModel
|
13
|
+
from nova.trame.view.components import InputField
|
14
|
+
from nova.trame.view_model.remote_file_input import RemoteFileInputViewModel
|
15
|
+
|
16
|
+
|
17
|
+
class RemoteFileInput:
|
18
|
+
"""Generates a file selection dialog for picking files off of the server.
|
19
|
+
|
20
|
+
You cannot use typical Trame :code:`with` syntax to add children to this.
|
21
|
+
"""
|
22
|
+
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
v_model: Optional[str] = None,
|
26
|
+
allow_files: bool = True,
|
27
|
+
allow_folders: bool = False,
|
28
|
+
allow_nonexistent_path: bool = False,
|
29
|
+
base_paths: Optional[list[str]] = None,
|
30
|
+
dialog_props: Optional[dict[str, Any]] = None,
|
31
|
+
extensions: Optional[list[str]] = None,
|
32
|
+
input_props: Optional[dict[str, Any]] = None,
|
33
|
+
label: str = "",
|
34
|
+
) -> None:
|
35
|
+
"""Constructor for RemoteFileInput.
|
36
|
+
|
37
|
+
Parameters
|
38
|
+
----------
|
39
|
+
v_model : str
|
40
|
+
The v-model for the input field.
|
41
|
+
allow_files : bool
|
42
|
+
If true, the user can save a file selection.
|
43
|
+
allow_folders : bool
|
44
|
+
If true, the user can save a folder selection.
|
45
|
+
allow_nonexistent_path : bool
|
46
|
+
If false, the user will be warned when they've selected a non-existent path on the filesystem.
|
47
|
+
base_paths : list[str], optional
|
48
|
+
Only files under these paths will be shown.
|
49
|
+
dialog_props : dict[str, typing.Any], optional
|
50
|
+
Props to be passed to VDialog.
|
51
|
+
extensions : list[str], optional
|
52
|
+
Only files with these extensions will be shown by default. The user can still choose to view all files.
|
53
|
+
input_props : dict[str, typing.Any], optional
|
54
|
+
Props to be passed to InputField. Must not include label prop, use the top-level label parameter instead.
|
55
|
+
label : str
|
56
|
+
Label shown in the input field and the dialog title.
|
57
|
+
|
58
|
+
Raises
|
59
|
+
------
|
60
|
+
ValueError
|
61
|
+
If v_model is None.
|
62
|
+
|
63
|
+
Returns
|
64
|
+
-------
|
65
|
+
None
|
66
|
+
"""
|
67
|
+
if v_model is None:
|
68
|
+
raise ValueError("RemoteFileInput must have a v_model attribute.")
|
69
|
+
|
70
|
+
self.v_model = v_model
|
71
|
+
self.allow_files = allow_files
|
72
|
+
self.allow_folders = allow_folders
|
73
|
+
self.allow_nonexistent_path = allow_nonexistent_path
|
74
|
+
self.base_paths = base_paths if base_paths else ["/"]
|
75
|
+
self.dialog_props = dict(dialog_props) if dialog_props else {}
|
76
|
+
self.extensions = extensions if extensions else []
|
77
|
+
self.input_props = dict(input_props) if input_props else {}
|
78
|
+
self.label = label
|
79
|
+
|
80
|
+
if "__events" not in self.input_props:
|
81
|
+
self.input_props["__events"] = []
|
82
|
+
self.input_props["__events"].append("change")
|
83
|
+
|
84
|
+
if "width" not in self.dialog_props:
|
85
|
+
self.dialog_props["width"] = 600
|
86
|
+
|
87
|
+
self.create_model()
|
88
|
+
self.create_viewmodel()
|
89
|
+
self.create_ui()
|
90
|
+
|
91
|
+
def create_ui(self) -> None:
|
92
|
+
with cast(
|
93
|
+
AbstractElement,
|
94
|
+
InputField(
|
95
|
+
v_model=self.v_model,
|
96
|
+
label=self.label,
|
97
|
+
change=(self.vm.select_file, "[$event.target.value]"),
|
98
|
+
**self.input_props,
|
99
|
+
),
|
100
|
+
):
|
101
|
+
self.vm.init_view()
|
102
|
+
|
103
|
+
with vuetify.Template(v_slot_append=True):
|
104
|
+
with vuetify.VBtn(icon=True, size="small", click=self.vm.open_dialog):
|
105
|
+
vuetify.VIcon("mdi-folder-open")
|
106
|
+
|
107
|
+
with vuetify.VDialog(
|
108
|
+
v_model=self.vm.get_dialog_state_name(),
|
109
|
+
activator="parent",
|
110
|
+
persistent=True,
|
111
|
+
**self.dialog_props,
|
112
|
+
):
|
113
|
+
with vuetify.VCard(classes="pa-4"):
|
114
|
+
vuetify.VCardTitle(self.label)
|
115
|
+
vuetify.VTextField(
|
116
|
+
v_model=self.v_model,
|
117
|
+
classes="mb-4 px-4",
|
118
|
+
label="Current Selection",
|
119
|
+
__events=["change"],
|
120
|
+
change=(self.vm.select_file, "[$event.target.value]"),
|
121
|
+
)
|
122
|
+
|
123
|
+
if self.allow_files and self.extensions:
|
124
|
+
with html.Div(v_if=(f"{self.vm.get_showing_all_state_name()}",)):
|
125
|
+
vuetify.VListSubheader("All Available Files")
|
126
|
+
vuetify.VBtn(
|
127
|
+
"Don't show all",
|
128
|
+
classes="mb-4",
|
129
|
+
size="small",
|
130
|
+
click=self.vm.toggle_showing_all_files,
|
131
|
+
)
|
132
|
+
with html.Div(v_else=True):
|
133
|
+
vuetify.VListSubheader(
|
134
|
+
f"Available Files with Extensions: {', '.join(self.extensions)}"
|
135
|
+
)
|
136
|
+
vuetify.VBtn(
|
137
|
+
"Show all",
|
138
|
+
classes="mb-4",
|
139
|
+
size="small",
|
140
|
+
click=self.vm.toggle_showing_all_files,
|
141
|
+
)
|
142
|
+
elif self.allow_files:
|
143
|
+
vuetify.VListSubheader("Available Files")
|
144
|
+
else:
|
145
|
+
vuetify.VListSubheader("Available Folders")
|
146
|
+
|
147
|
+
with vuetify.VList(classes="mb-4"):
|
148
|
+
self.vm.populate_file_list()
|
149
|
+
|
150
|
+
vuetify.VListItem(
|
151
|
+
"{{ file.path }}",
|
152
|
+
v_for=f"file, index in {self.vm.get_file_list_state_name()}",
|
153
|
+
classes=(
|
154
|
+
f"index < {self.vm.get_file_list_state_name()}.length - 1 "
|
155
|
+
"? 'border-b-thin' "
|
156
|
+
": ''",
|
157
|
+
),
|
158
|
+
prepend_icon=("file.directory ? 'mdi-folder' : 'mdi-file'",),
|
159
|
+
click=(self.vm.select_file, "[file]"),
|
160
|
+
)
|
161
|
+
|
162
|
+
with html.Div(classes="text-center"):
|
163
|
+
vuetify.VBtn(
|
164
|
+
"OK",
|
165
|
+
classes="mr-4",
|
166
|
+
disabled=(f"!{self.vm.get_valid_selection_state_name()}",),
|
167
|
+
click=self.vm.close_dialog,
|
168
|
+
)
|
169
|
+
vuetify.VBtn(
|
170
|
+
"Cancel",
|
171
|
+
color="lightgrey",
|
172
|
+
click=partial(self.vm.close_dialog, cancel=True),
|
173
|
+
)
|
174
|
+
|
175
|
+
def create_model(self) -> None:
|
176
|
+
self.model = RemoteFileInputModel(self.allow_files, self.allow_folders, self.base_paths, self.extensions)
|
177
|
+
|
178
|
+
def create_viewmodel(self) -> None:
|
179
|
+
server = get_server(None, client_type="vue3")
|
180
|
+
binding = TrameBinding(server.state)
|
181
|
+
|
182
|
+
self.vm = RemoteFileInputViewModel(self.model, binding)
|
183
|
+
|
184
|
+
self.vm.dialog_bind.connect(self.vm.get_dialog_state_name())
|
185
|
+
self.vm.file_list_bind.connect(self.vm.get_file_list_state_name())
|
186
|
+
self.vm.on_close_bind.connect(client.JSEval(exec=f"{self.vm.get_dialog_state_name()} = false;").exec)
|
187
|
+
self.vm.on_update_bind.connect(
|
188
|
+
client.JSEval(
|
189
|
+
exec=f"{self.v_model} = $event; flushState('{self.v_model.split('.')[0].split('[')[0]}');"
|
190
|
+
).exec
|
191
|
+
)
|
192
|
+
self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name())
|
193
|
+
self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name())
|
@@ -0,0 +1,85 @@
|
|
1
|
+
"""View implementation for Interactive2DPlot."""
|
2
|
+
|
3
|
+
from typing import Any, Optional
|
4
|
+
|
5
|
+
from altair import Chart
|
6
|
+
from trame.widgets import client, vega
|
7
|
+
|
8
|
+
|
9
|
+
class Interactive2DPlot(vega.Figure):
|
10
|
+
"""Creates an interactive 2D plot in Trame using Vega.
|
11
|
+
|
12
|
+
Trame provides two primary mechanisms for composing 2D plots: `Plotly <https://github.com/Kitware/trame-plotly>`_
|
13
|
+
and `Vega-Lite/Altair <https://github.com/Kitware/trame-vega>`_. If you only need static plots or basic browser
|
14
|
+
event handling, then please use these libraries directly.
|
15
|
+
|
16
|
+
If you need to capture complex front-end interactions, then you can
|
17
|
+
use our provided Interactive2DPlot widget that is based on Vega-Lite. This uses the same API as Trame's vega.Figure,
|
18
|
+
except that it will automatically sync Vega's signal states as the user interacts with the plot.
|
19
|
+
|
20
|
+
The following allows the user to select a region of the plot and tracks the selected region in Python:
|
21
|
+
|
22
|
+
.. literalinclude:: ../tests/test_interactive_2d_plot.py
|
23
|
+
:start-after: setup 2d plot
|
24
|
+
:end-before: setup 2d plot complete
|
25
|
+
:dedent:
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, figure: Optional[Chart] = None, **kwargs: Any) -> None:
|
29
|
+
"""Constructor for Interactive2DPlot.
|
30
|
+
|
31
|
+
Parameters
|
32
|
+
----------
|
33
|
+
figure : `altair.Chart <https://altair-viz.github.io/user_guide/generated/toplevel/altair.Chart.html#altair.Chart>`_
|
34
|
+
Altair chart object
|
35
|
+
kwargs
|
36
|
+
Arguments to be passed to `AbstractElement <https://trame.readthedocs.io/en/latest/core.widget.html#trame_client.widgets.core.AbstractElement>`_
|
37
|
+
|
38
|
+
Returns
|
39
|
+
-------
|
40
|
+
None
|
41
|
+
"""
|
42
|
+
self._initialized = False
|
43
|
+
|
44
|
+
super().__init__(figure=figure, **kwargs)
|
45
|
+
self.ref = f"nova__vega_{self._id}"
|
46
|
+
self.server.state[self.ref] = {}
|
47
|
+
self._start_update_handlers = client.JSEval(
|
48
|
+
exec=(
|
49
|
+
"async () => {"
|
50
|
+
f" let ref = window.trame.refs['{self.ref}'];"
|
51
|
+
" await ref.mountVis();" # wait for the new visualization to be rendered in the front-end
|
52
|
+
" if (ref.viz === undefined) { return; }" # If the component is not mounted, do nothing
|
53
|
+
" for (const [key, value] of Object.entries(ref.viz.view._signals)) {"
|
54
|
+
" if (key === 'unit') { continue; }" # this causes a JSError for some reason if not skipped
|
55
|
+
" ref.viz.view.addSignalListener(key, (name, value) => {"
|
56
|
+
f" window.trame.state.state['{self.ref}'][name] = value;" # sync front-end state
|
57
|
+
f" flushState('{self.ref}');" # sync back-end state
|
58
|
+
" })"
|
59
|
+
" }"
|
60
|
+
"}"
|
61
|
+
)
|
62
|
+
).exec
|
63
|
+
|
64
|
+
client.ClientTriggers(mounted=self.update)
|
65
|
+
|
66
|
+
def get_signal_state(self, name: str) -> Any:
|
67
|
+
"""Retrieves a Vega signal's state by name.
|
68
|
+
|
69
|
+
Parameters
|
70
|
+
----------
|
71
|
+
name : str
|
72
|
+
The name of the signal to retrieve.
|
73
|
+
|
74
|
+
Returns
|
75
|
+
-------
|
76
|
+
typing.Any
|
77
|
+
The current value of the Vega signal.
|
78
|
+
"""
|
79
|
+
return self.server.state[self.ref].get(name, None)
|
80
|
+
|
81
|
+
def update(self, figure: Optional[Chart] = None, **kwargs: Any) -> None:
|
82
|
+
super().update(figure=figure, **kwargs)
|
83
|
+
|
84
|
+
if hasattr(self, "_start_update_handlers"):
|
85
|
+
self._start_update_handlers()
|