nova-trame 0.13.1__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- nova/__init__.py +1 -0
- nova/trame/__init__.py +3 -0
- nova/trame/model/remote_file_input.py +109 -0
- nova/trame/view/components/__init__.py +4 -0
- nova/trame/view/components/input_field.py +302 -0
- nova/trame/view/components/remote_file_input.py +191 -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 +180 -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 +93 -0
- nova_trame-0.13.1.dist-info/LICENSE +21 -0
- nova_trame-0.13.1.dist-info/METADATA +40 -0
- nova_trame-0.13.1.dist-info/RECORD +24 -0
- nova_trame-0.13.1.dist-info/WHEEL +4 -0
- nova_trame-0.13.1.dist-info/entry_points.txt +3 -0
nova/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
nova/trame/__init__.py
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
"""Model state for RemoteFileInput."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from functools import cmp_to_key
|
5
|
+
from locale import strcoll
|
6
|
+
from typing import Any, Union
|
7
|
+
|
8
|
+
|
9
|
+
class RemoteFileInputModel:
|
10
|
+
"""Manages interactions between RemoteFileInput and the file system."""
|
11
|
+
|
12
|
+
def __init__(self, allow_files: bool, allow_folders: bool, base_paths: list[str], extensions: list[str]) -> None:
|
13
|
+
"""Creates a new RemoteFileInputModel."""
|
14
|
+
self.allow_files = allow_files
|
15
|
+
self.allow_folders = allow_folders
|
16
|
+
self.base_paths = base_paths
|
17
|
+
self.extensions = extensions
|
18
|
+
|
19
|
+
def get_base_paths(self) -> list[dict[str, Any]]:
|
20
|
+
return [{"path": base_path, "directory": True} for base_path in self.base_paths]
|
21
|
+
|
22
|
+
def scan_current_path(
|
23
|
+
self, current_path: str, showing_all_files: bool, filter: str
|
24
|
+
) -> tuple[list[dict[str, Any]], bool]:
|
25
|
+
failed = False
|
26
|
+
filter = filter.split("/")[-1]
|
27
|
+
|
28
|
+
try:
|
29
|
+
if current_path and (not self.valid_subpath(current_path) or not os.path.exists(current_path)):
|
30
|
+
raise FileNotFoundError
|
31
|
+
|
32
|
+
files = [{"path": "..", "directory": True}]
|
33
|
+
|
34
|
+
if os.path.isdir(current_path):
|
35
|
+
scan_path = current_path
|
36
|
+
else:
|
37
|
+
scan_path = os.path.dirname(current_path)
|
38
|
+
|
39
|
+
for entry in os.scandir(scan_path):
|
40
|
+
if self.valid_entry(entry, showing_all_files) and (not filter or entry.name.startswith(filter)):
|
41
|
+
files.append({"path": entry.name, "directory": entry.is_dir()})
|
42
|
+
except OSError:
|
43
|
+
files = self.get_base_paths()
|
44
|
+
failed = True
|
45
|
+
|
46
|
+
def _sort_files(a: dict[str, Any], b: dict[str, Any]) -> int:
|
47
|
+
if a["directory"] and not b["directory"]:
|
48
|
+
return -1
|
49
|
+
if b["directory"] and not a["directory"]:
|
50
|
+
return 1
|
51
|
+
|
52
|
+
path_a = a["path"].lower()
|
53
|
+
path_b = b["path"].lower()
|
54
|
+
|
55
|
+
return strcoll(path_a, path_b)
|
56
|
+
|
57
|
+
sorted_files = sorted(files, key=cmp_to_key(_sort_files))
|
58
|
+
|
59
|
+
return (sorted_files, failed)
|
60
|
+
|
61
|
+
def select_file(self, file: Union[dict[str, str], str], old_path: str, showing_base_paths: bool) -> str:
|
62
|
+
if file == "":
|
63
|
+
return ""
|
64
|
+
|
65
|
+
if isinstance(file, dict):
|
66
|
+
file = file["path"]
|
67
|
+
|
68
|
+
if not os.path.isdir(old_path):
|
69
|
+
# If the previous selection is a file, then we need to append to its parent directory
|
70
|
+
old_path = os.path.dirname(old_path)
|
71
|
+
|
72
|
+
if not showing_base_paths and file != "..":
|
73
|
+
return os.path.join(old_path, file)
|
74
|
+
elif not showing_base_paths:
|
75
|
+
if old_path in self.base_paths:
|
76
|
+
return ""
|
77
|
+
else:
|
78
|
+
return os.path.dirname(old_path)
|
79
|
+
else:
|
80
|
+
return file
|
81
|
+
|
82
|
+
def valid_entry(self, entry: os.DirEntry, showing_all_files: bool) -> bool:
|
83
|
+
if entry.is_dir():
|
84
|
+
return True
|
85
|
+
|
86
|
+
if not self.allow_files:
|
87
|
+
return False
|
88
|
+
|
89
|
+
return showing_all_files or not self.extensions or any(entry.name.endswith(ext) for ext in self.extensions)
|
90
|
+
|
91
|
+
def valid_selection(self, selection: str) -> bool:
|
92
|
+
if self.valid_subpath(selection):
|
93
|
+
if os.path.isdir(selection) and self.allow_folders:
|
94
|
+
return True
|
95
|
+
|
96
|
+
if os.path.isfile(selection) and self.allow_files:
|
97
|
+
return True
|
98
|
+
|
99
|
+
return False
|
100
|
+
|
101
|
+
def valid_subpath(self, subpath: str) -> bool:
|
102
|
+
if subpath == "":
|
103
|
+
return False
|
104
|
+
|
105
|
+
for base_path in self.base_paths:
|
106
|
+
if subpath.startswith(base_path):
|
107
|
+
return True
|
108
|
+
|
109
|
+
return False
|
@@ -0,0 +1,302 @@
|
|
1
|
+
"""View Implementation for InputField."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
from typing import Any, Dict, Optional, Union
|
6
|
+
|
7
|
+
from trame.app import get_server
|
8
|
+
from trame.widgets import client
|
9
|
+
from trame.widgets import vuetify3 as vuetify
|
10
|
+
from trame_client.widgets.core import AbstractElement
|
11
|
+
from trame_server.controller import Controller
|
12
|
+
from trame_server.state import State
|
13
|
+
|
14
|
+
from nova.mvvm.pydantic_utils import get_field_info
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class InputField:
|
20
|
+
"""Factory class for generating Vuetify input components."""
|
21
|
+
|
22
|
+
@staticmethod
|
23
|
+
def create_boilerplate_properties(v_model: Optional[Union[tuple[str, Any], str]]) -> dict:
|
24
|
+
if not v_model:
|
25
|
+
return {}
|
26
|
+
if isinstance(v_model, tuple):
|
27
|
+
field = v_model[0]
|
28
|
+
else:
|
29
|
+
field = v_model
|
30
|
+
object_name_in_state = field.split(".")[0]
|
31
|
+
field_info = None
|
32
|
+
try:
|
33
|
+
field_name = ".".join(field.split(".")[1:])
|
34
|
+
if "[" in field_name:
|
35
|
+
index_field_name = re.sub(r"\[.*?\]", "[0]", field_name)
|
36
|
+
field_info = get_field_info(f"{object_name_in_state}.{index_field_name}")
|
37
|
+
if "[" in field_name and "[index]" not in field_name:
|
38
|
+
field_info = None
|
39
|
+
logger.warning(
|
40
|
+
f"{field}: validation ignored. We currently only "
|
41
|
+
f"support single loop with index variable that should be called 'index'"
|
42
|
+
)
|
43
|
+
else:
|
44
|
+
field_info = get_field_info(field)
|
45
|
+
except Exception as _:
|
46
|
+
pass
|
47
|
+
label = ""
|
48
|
+
help_dict: dict = {}
|
49
|
+
placeholder = None
|
50
|
+
if field_info:
|
51
|
+
label = field_info.title
|
52
|
+
if field_info.examples and len(field_info.examples) > 0:
|
53
|
+
placeholder = field_info.examples[0]
|
54
|
+
help_dict = {"hint": field_info.description, "placeholder": placeholder}
|
55
|
+
|
56
|
+
args: Dict[str, Any] = {}
|
57
|
+
if v_model:
|
58
|
+
args |= {
|
59
|
+
"v_model": v_model,
|
60
|
+
"label": label,
|
61
|
+
"help": help_dict,
|
62
|
+
"update_modelValue": f"flushState('{object_name_in_state}')",
|
63
|
+
}
|
64
|
+
if field_info:
|
65
|
+
args |= {
|
66
|
+
"rules": (f"[(v) => trigger('validate_pydantic_field', ['{field}', v, index])]",),
|
67
|
+
}
|
68
|
+
return args
|
69
|
+
|
70
|
+
def __new__(
|
71
|
+
cls,
|
72
|
+
v_model: Optional[Union[tuple[str, Any], str]] = None,
|
73
|
+
required: bool = False,
|
74
|
+
type: str = "text",
|
75
|
+
**kwargs: Any,
|
76
|
+
) -> AbstractElement:
|
77
|
+
"""Constructor for InputField.
|
78
|
+
|
79
|
+
Parameters
|
80
|
+
----------
|
81
|
+
v_model : tuple[str, Any] or str, optional
|
82
|
+
The v-model for this component. If this references a Pydantic configuration variable, then this component
|
83
|
+
will attempt to load a label, hint, and validation rules from the configuration for you automatically.
|
84
|
+
required : bool
|
85
|
+
If true, the input will be visually marked as required and a required rule will be added to the end of the
|
86
|
+
rules list.
|
87
|
+
type : str
|
88
|
+
The type of input to create. This can be any of the following:
|
89
|
+
|
90
|
+
- autocomplete
|
91
|
+
- autoscroll (produces a textarea that automatically scrolls to the bottom as new content is added)
|
92
|
+
- checkbox
|
93
|
+
- combobox
|
94
|
+
- file
|
95
|
+
- input
|
96
|
+
- otp
|
97
|
+
- radio
|
98
|
+
- range-slider
|
99
|
+
- select
|
100
|
+
- slider
|
101
|
+
- switch
|
102
|
+
- textarea
|
103
|
+
|
104
|
+
Any other value will produce a text field with your type used as an HTML input type attribute.
|
105
|
+
**kwargs
|
106
|
+
All other arguments will be passed to the underlying
|
107
|
+
`Trame Vuetify component <https://trame.readthedocs.io/en/latest/trame.widgets.vuetify3.html>`_.
|
108
|
+
The following example would set the auto_grow and label attributes on
|
109
|
+
`VTextarea <https://trame.readthedocs.io/en/latest/trame.widgets.vuetify3.html#trame.widgets.vuetify3.VTextarea>`_:
|
110
|
+
|
111
|
+
.. literalinclude:: ../tests/gallery/app.py
|
112
|
+
:start-after: InputField kwargs example start
|
113
|
+
:end-before: InputField kwargs example end
|
114
|
+
:dedent:
|
115
|
+
|
116
|
+
Returns
|
117
|
+
-------
|
118
|
+
`trame_client.widgets.core.AbstractElement <https://trame.readthedocs.io/en/latest/core.widget.html#trame_client.widgets.core.AbstractElement>`_
|
119
|
+
The Vuetify input component.
|
120
|
+
"""
|
121
|
+
server = get_server(None, client_type="vue3")
|
122
|
+
|
123
|
+
kwargs = {**cls.create_boilerplate_properties(v_model), **kwargs}
|
124
|
+
|
125
|
+
if "__events" not in kwargs or kwargs["__events"] is None:
|
126
|
+
kwargs["__events"] = []
|
127
|
+
|
128
|
+
# This must be present before each input is created or change events won't be triggered.
|
129
|
+
if isinstance(kwargs["__events"], list):
|
130
|
+
if "blur" not in kwargs["__events"]:
|
131
|
+
kwargs["__events"].append("blur")
|
132
|
+
if "change" not in kwargs["__events"]:
|
133
|
+
kwargs["__events"].append("change")
|
134
|
+
if "scroll" not in kwargs["__events"]:
|
135
|
+
kwargs["__events"].append("scroll")
|
136
|
+
|
137
|
+
match type:
|
138
|
+
case "autocomplete":
|
139
|
+
input = vuetify.VAutocomplete(**kwargs)
|
140
|
+
case "autoscroll":
|
141
|
+
input = vuetify.VTextarea(**kwargs)
|
142
|
+
cls._setup_autoscroll(server.state, input)
|
143
|
+
case "checkbox":
|
144
|
+
input = vuetify.VCheckbox(**kwargs)
|
145
|
+
case "combobox":
|
146
|
+
input = vuetify.VCombobox(**kwargs)
|
147
|
+
case "file":
|
148
|
+
input = vuetify.VFileInput(**kwargs)
|
149
|
+
case "input":
|
150
|
+
input = vuetify.VInput(**kwargs)
|
151
|
+
case "otp":
|
152
|
+
input = vuetify.VOtpInput(**kwargs)
|
153
|
+
case "radio":
|
154
|
+
input = vuetify.VRadioGroup(**kwargs)
|
155
|
+
case "range-slider":
|
156
|
+
input = vuetify.VRangeSlider(**kwargs)
|
157
|
+
case "select":
|
158
|
+
items = kwargs.pop("items", None)
|
159
|
+
if isinstance(items, str):
|
160
|
+
items = (items,)
|
161
|
+
|
162
|
+
input = vuetify.VSelect(items=items, **kwargs)
|
163
|
+
case "slider":
|
164
|
+
input = vuetify.VSlider(**kwargs)
|
165
|
+
case "switch":
|
166
|
+
input = vuetify.VSwitch(**kwargs)
|
167
|
+
case "textarea":
|
168
|
+
input = vuetify.VTextarea(**kwargs)
|
169
|
+
case _:
|
170
|
+
input = vuetify.VTextField(type=type, **kwargs)
|
171
|
+
|
172
|
+
cls._setup_ref(input)
|
173
|
+
cls._setup_help(input, **kwargs)
|
174
|
+
|
175
|
+
cls._check_rules(input)
|
176
|
+
if required:
|
177
|
+
cls._setup_required_label(input)
|
178
|
+
cls._setup_required_rule(input)
|
179
|
+
|
180
|
+
cls._setup_event_listeners(server.controller, input)
|
181
|
+
|
182
|
+
return input
|
183
|
+
|
184
|
+
@staticmethod
|
185
|
+
def _check_rules(input: AbstractElement) -> None:
|
186
|
+
if "rules" in input._py_attr and input.rules and not isinstance(input.rules, tuple):
|
187
|
+
raise ValueError(f"Rules for '{input.label}' must be a tuple")
|
188
|
+
|
189
|
+
@staticmethod
|
190
|
+
def _setup_autoscroll(state: State, input: AbstractElement) -> None:
|
191
|
+
if input.v_model:
|
192
|
+
if "id" not in input._py_attr or input.id is None:
|
193
|
+
input.id = f"nova__{input._id}"
|
194
|
+
input.scroll = f"window.nova__autoscroll('{input.id}');"
|
195
|
+
|
196
|
+
with state:
|
197
|
+
if state["nova_scroll_position"] is None:
|
198
|
+
state["nova_scroll_position"] = {}
|
199
|
+
state.nova_scroll_position[input.id] = 0
|
200
|
+
|
201
|
+
autoscroll = client.JSEval(
|
202
|
+
exec=(
|
203
|
+
"if (window.nova__autoscroll !== undefined) {"
|
204
|
+
# If the autoscroll function already exists, call it.
|
205
|
+
" window.nova__autoscroll($event);"
|
206
|
+
"} else {"
|
207
|
+
# Save the JS so it can be called from outside of this script (ie during a scroll event).
|
208
|
+
" window.nova__autoscroll = function(id) {"
|
209
|
+
# Get the element in the browser by ID
|
210
|
+
" const element = window.document.querySelector(`#${id}`);"
|
211
|
+
# If the user is at the bottom of the textarea, then we should autoscroll.
|
212
|
+
" if (element.scrollTop === window.trame.state.state.nova_scroll_position[id]) {"
|
213
|
+
# Scroll to the bottom
|
214
|
+
" element.scrollTop = element.scrollHeight;"
|
215
|
+
# Save the new scroll position
|
216
|
+
" window.trame.state.state.nova_scroll_position[id] = element.scrollTop;"
|
217
|
+
" flushState('nova_scroll_position');"
|
218
|
+
# If the user has scrolled back to the bottom, then we should reenable scrolling.
|
219
|
+
" } else if (element.scrollTop === element.scrollHeight - element.clientHeight) {"
|
220
|
+
# Save the new scroll position
|
221
|
+
" window.trame.state.state.nova_scroll_position[id] = element.scrollTop;"
|
222
|
+
" flushState('nova_scroll_position');"
|
223
|
+
" }"
|
224
|
+
" };"
|
225
|
+
" window.nova__autoscroll($event);"
|
226
|
+
"}"
|
227
|
+
)
|
228
|
+
).exec
|
229
|
+
|
230
|
+
@state.change(input.v_model)
|
231
|
+
def _autoscroll(**kwargs: Any) -> None:
|
232
|
+
autoscroll(input.id)
|
233
|
+
|
234
|
+
@staticmethod
|
235
|
+
def _setup_help(_input: AbstractElement, **kwargs: Any) -> None:
|
236
|
+
help = kwargs.get("help", None)
|
237
|
+
if help and isinstance(help, dict):
|
238
|
+
_input.hint = help.get("hint", None)
|
239
|
+
_input.placeholder = help.get("placeholder", None)
|
240
|
+
|
241
|
+
@staticmethod
|
242
|
+
def _setup_required_label(input: AbstractElement) -> None:
|
243
|
+
if input.label:
|
244
|
+
input.label = f"{input.label}*"
|
245
|
+
else:
|
246
|
+
input.label = "*"
|
247
|
+
|
248
|
+
@staticmethod
|
249
|
+
def _setup_ref(input: AbstractElement) -> None:
|
250
|
+
if "ref" not in input._py_attr or input.ref is None:
|
251
|
+
input.ref = f"nova__{input._id}"
|
252
|
+
|
253
|
+
@staticmethod
|
254
|
+
def _setup_required_rule(input: AbstractElement) -> None:
|
255
|
+
# The rule needs to check that 1. the input has been touched by the user, and 2. the input is not empty.
|
256
|
+
required_rule = (
|
257
|
+
f"(value) => (!window.trame.refs['{input.ref}'].touched || value?.length > 0) || 'Field is required'"
|
258
|
+
)
|
259
|
+
if "rules" in input._py_attr and input.rules:
|
260
|
+
# Existing rules will be in format ("[rule1, rule2]",) and we need to append to this list
|
261
|
+
rule_end_index = input.rules[0].rindex("]")
|
262
|
+
input.rules = (f"{input.rules[0][:rule_end_index]}, {required_rule}{input.rules[0][rule_end_index:]}",)
|
263
|
+
else:
|
264
|
+
input.rules = (f"[{required_rule}]",)
|
265
|
+
|
266
|
+
@staticmethod
|
267
|
+
def _setup_event_listeners(ctrl: Controller, input: AbstractElement) -> None:
|
268
|
+
base_handler = None
|
269
|
+
if "change" in input._py_attr and input.change is not None:
|
270
|
+
base_handler = input.change
|
271
|
+
|
272
|
+
# Iterate over all saved refs and perform validation if there is a value that can be validated.
|
273
|
+
change_handler = (
|
274
|
+
"Object.values(window.trame.refs).map("
|
275
|
+
" (ref) => ref && typeof ref.validate === 'function' && ref.value ? ref.validate() : null"
|
276
|
+
");"
|
277
|
+
)
|
278
|
+
|
279
|
+
# We need to coerce the developer's change handler, which could be a string, callable, or tuple containing a
|
280
|
+
# callable, to a single string to be compatible with our change handler.
|
281
|
+
if callable(base_handler):
|
282
|
+
base_handler = (base_handler,)
|
283
|
+
if isinstance(base_handler, tuple):
|
284
|
+
|
285
|
+
@ctrl.trigger(f"{input.ref}__trigger")
|
286
|
+
def _(*args: str, **kwargs: Any) -> None:
|
287
|
+
base_handler[0](*args, **kwargs)
|
288
|
+
|
289
|
+
change_handler = (
|
290
|
+
"trigger("
|
291
|
+
f"'{input.ref}__trigger', "
|
292
|
+
f"{base_handler[1] if len(base_handler) > 1 else []}, "
|
293
|
+
f"{base_handler[2] if len(base_handler) > 2 else {} }"
|
294
|
+
f"); {change_handler}"
|
295
|
+
) # Call the developer's provided change method via a trigger, then call ours.
|
296
|
+
elif isinstance(base_handler, str):
|
297
|
+
# Call the developer's provided change JS expression, then call ours.
|
298
|
+
change_handler = f"{base_handler}; {change_handler}"
|
299
|
+
|
300
|
+
# The user touched the input, so we can enable the required rule.
|
301
|
+
input.blur = f"window.trame.refs['{input.ref}'].touched = true"
|
302
|
+
input.change = change_handler
|
@@ -0,0 +1,191 @@
|
|
1
|
+
"""View implementation for RemoteFileInput."""
|
2
|
+
|
3
|
+
from functools import partial
|
4
|
+
from typing import Any, Optional, Union, cast
|
5
|
+
|
6
|
+
from trame.app import get_server
|
7
|
+
from trame.widgets import client, html
|
8
|
+
from trame.widgets import vuetify3 as vuetify
|
9
|
+
from trame_client.widgets.core import AbstractElement
|
10
|
+
|
11
|
+
from nova.mvvm.trame_binding import TrameBinding
|
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[Union[tuple[str, Any], 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
|
+
) -> None:
|
34
|
+
"""Constructor for RemoteFileInput.
|
35
|
+
|
36
|
+
Parameters
|
37
|
+
----------
|
38
|
+
v_model : tuple[str, Any] or str, optional
|
39
|
+
The v-model for this component. If this references a Pydantic configuration variable, then this component
|
40
|
+
will attempt to load a label, hint, and validation rules from the configuration for you automatically.
|
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.
|
55
|
+
|
56
|
+
Raises
|
57
|
+
------
|
58
|
+
ValueError
|
59
|
+
If v_model is None.
|
60
|
+
|
61
|
+
Returns
|
62
|
+
-------
|
63
|
+
None
|
64
|
+
"""
|
65
|
+
if v_model is None:
|
66
|
+
raise ValueError("RemoteFileInput must have a v_model attribute.")
|
67
|
+
|
68
|
+
self.v_model = v_model
|
69
|
+
self.allow_files = allow_files
|
70
|
+
self.allow_folders = allow_folders
|
71
|
+
self.allow_nonexistent_path = allow_nonexistent_path
|
72
|
+
self.base_paths = base_paths if base_paths else ["/"]
|
73
|
+
self.dialog_props = dict(dialog_props) if dialog_props else {}
|
74
|
+
self.extensions = extensions if extensions else []
|
75
|
+
self.input_props = dict(input_props) if input_props else {}
|
76
|
+
|
77
|
+
if "__events" not in self.input_props:
|
78
|
+
self.input_props["__events"] = []
|
79
|
+
self.input_props["__events"].append("change")
|
80
|
+
|
81
|
+
if "width" not in self.dialog_props:
|
82
|
+
self.dialog_props["width"] = 600
|
83
|
+
|
84
|
+
self.create_model()
|
85
|
+
self.create_viewmodel()
|
86
|
+
self.create_ui()
|
87
|
+
|
88
|
+
def create_ui(self) -> None:
|
89
|
+
with cast(
|
90
|
+
AbstractElement,
|
91
|
+
InputField(
|
92
|
+
v_model=self.v_model,
|
93
|
+
change=(self.vm.select_file, "[$event.target.value]"),
|
94
|
+
**self.input_props,
|
95
|
+
),
|
96
|
+
) as input:
|
97
|
+
self.vm.init_view()
|
98
|
+
|
99
|
+
with vuetify.Template(v_slot_append=True):
|
100
|
+
with vuetify.VBtn(icon=True, size="small", click=self.vm.open_dialog):
|
101
|
+
vuetify.VIcon("mdi-folder-open")
|
102
|
+
|
103
|
+
with vuetify.VDialog(
|
104
|
+
v_model=self.vm.get_dialog_state_name(), activator="parent", **self.dialog_props
|
105
|
+
):
|
106
|
+
with vuetify.VCard(classes="pa-4"):
|
107
|
+
vuetify.VCardTitle(input.label)
|
108
|
+
vuetify.VTextField(
|
109
|
+
v_model=self.vm.get_filter_state_name(),
|
110
|
+
classes="mb-4 px-4",
|
111
|
+
label="Current Selection",
|
112
|
+
__events=["change"],
|
113
|
+
change=(self.vm.select_file, "[$event.target.value]"),
|
114
|
+
update_modelValue=(self.vm.filter_paths, "[$event]"),
|
115
|
+
)
|
116
|
+
|
117
|
+
if self.allow_files and self.extensions:
|
118
|
+
with html.Div(v_if=(f"{self.vm.get_showing_all_state_name()}",)):
|
119
|
+
vuetify.VListSubheader("All Available Files")
|
120
|
+
vuetify.VBtn(
|
121
|
+
"Don't show all",
|
122
|
+
classes="mb-4",
|
123
|
+
size="small",
|
124
|
+
click=self.vm.toggle_showing_all_files,
|
125
|
+
)
|
126
|
+
with html.Div(v_else=True):
|
127
|
+
vuetify.VListSubheader(
|
128
|
+
f"Available Files with Extensions: {', '.join(self.extensions)}"
|
129
|
+
)
|
130
|
+
vuetify.VBtn(
|
131
|
+
"Show all",
|
132
|
+
classes="mb-4",
|
133
|
+
size="small",
|
134
|
+
click=self.vm.toggle_showing_all_files,
|
135
|
+
)
|
136
|
+
elif self.allow_files:
|
137
|
+
vuetify.VListSubheader("Available Files")
|
138
|
+
else:
|
139
|
+
vuetify.VListSubheader("Available Folders")
|
140
|
+
|
141
|
+
with vuetify.VList(classes="mb-4"):
|
142
|
+
self.vm.populate_file_list()
|
143
|
+
|
144
|
+
vuetify.VListItem(
|
145
|
+
"{{ file.path }}",
|
146
|
+
v_for=f"file, index in {self.vm.get_file_list_state_name()}",
|
147
|
+
classes=(
|
148
|
+
f"index < {self.vm.get_file_list_state_name()}.length - 1 "
|
149
|
+
"? 'border-b-thin' "
|
150
|
+
": ''",
|
151
|
+
),
|
152
|
+
prepend_icon=("file.directory ? 'mdi-folder' : 'mdi-file'",),
|
153
|
+
click=(self.vm.select_file, "[file]"),
|
154
|
+
)
|
155
|
+
|
156
|
+
with html.Div(classes="text-center"):
|
157
|
+
vuetify.VBtn(
|
158
|
+
"OK",
|
159
|
+
classes="mr-4",
|
160
|
+
disabled=(f"!{self.vm.get_valid_selection_state_name()}",),
|
161
|
+
click=self.vm.close_dialog,
|
162
|
+
)
|
163
|
+
vuetify.VBtn(
|
164
|
+
"Cancel",
|
165
|
+
color="lightgrey",
|
166
|
+
click=partial(self.vm.close_dialog, cancel=True),
|
167
|
+
)
|
168
|
+
|
169
|
+
def create_model(self) -> None:
|
170
|
+
self.model = RemoteFileInputModel(self.allow_files, self.allow_folders, self.base_paths, self.extensions)
|
171
|
+
|
172
|
+
def create_viewmodel(self) -> None:
|
173
|
+
server = get_server(None, client_type="vue3")
|
174
|
+
binding = TrameBinding(server.state)
|
175
|
+
|
176
|
+
if isinstance(self.v_model, tuple):
|
177
|
+
model_name = self.v_model[0]
|
178
|
+
else:
|
179
|
+
model_name = self.v_model
|
180
|
+
|
181
|
+
self.vm = RemoteFileInputViewModel(self.model, binding)
|
182
|
+
|
183
|
+
self.vm.dialog_bind.connect(self.vm.get_dialog_state_name())
|
184
|
+
self.vm.file_list_bind.connect(self.vm.get_file_list_state_name())
|
185
|
+
self.vm.filter_bind.connect(self.vm.get_filter_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(exec=f"{model_name} = $event; flushState('{model_name.split('.')[0].split('[')[0]}');").exec
|
189
|
+
)
|
190
|
+
self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name())
|
191
|
+
self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name())
|