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 ADDED
@@ -0,0 +1 @@
1
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
nova/trame/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .view.theme import ThemedApp
2
+
3
+ __all__ = ["ThemedApp"]
@@ -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,4 @@
1
+ from .input_field import InputField
2
+ from .remote_file_input import RemoteFileInput
3
+
4
+ __all__ = ["InputField", "RemoteFileInput"]
@@ -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())
@@ -0,0 +1,3 @@
1
+ from .interactive_2d_plot import Interactive2DPlot
2
+
3
+ __all__ = ["Interactive2DPlot"]