nova-trame 0.13.1__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 +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())
|