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 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"]