Loading src/nova/trame/model/remote_file_input.py +35 −13 Original line number Diff line number Diff line Loading @@ -3,21 +3,39 @@ import os from functools import cmp_to_key from locale import strcoll from typing import Any, Union from typing import Any, List, Union from pydantic import BaseModel, Field class RemoteFileInputState(BaseModel): """Pydantic model for RemoteFileInput state.""" allow_files: bool = Field(default=False) allow_folders: bool = Field(default=False) base_paths: List[str] = Field(default=[]) extensions: List[str] = Field(default=[]) class RemoteFileInputModel: """Manages interactions between RemoteFileInput and the file system.""" def __init__(self, allow_files: bool, allow_folders: bool, base_paths: list[str], extensions: list[str]) -> None: def __init__(self) -> None: """Creates a new RemoteFileInputModel.""" self.allow_files = allow_files self.allow_folders = allow_folders self.base_paths = base_paths self.extensions = extensions self.state = RemoteFileInputState() def set_binding_parameters(self, **kwargs: Any) -> None: if "allow_files" in kwargs: self.state.allow_files = kwargs["allow_files"] if "allow_folders" in kwargs: self.state.allow_folders = kwargs["allow_folders"] if "base_paths" in kwargs: self.state.base_paths = kwargs["base_paths"] if "extensions" in kwargs: self.state.extensions = kwargs["extensions"] def get_base_paths(self) -> list[dict[str, Any]]: return [{"path": base_path, "directory": True} for base_path in self.base_paths] return [{"path": base_path, "directory": True} for base_path in self.state.base_paths] def scan_current_path( self, current_path: str, showing_all_files: bool, filter: str Loading Loading @@ -72,7 +90,7 @@ class RemoteFileInputModel: if not showing_base_paths and file != "..": return os.path.join(old_path, file) elif not showing_base_paths: if old_path in self.base_paths: if old_path in self.state.base_paths: return "" else: return os.path.dirname(old_path) Loading @@ -83,17 +101,21 @@ class RemoteFileInputModel: if entry.is_dir(): return True if not self.allow_files: if not self.state.allow_files: return False return showing_all_files or not self.extensions or any(entry.name.endswith(ext) for ext in self.extensions) return ( showing_all_files or not self.state.extensions or any(entry.name.endswith(ext) for ext in self.state.extensions) ) def valid_selection(self, selection: str) -> bool: if self.valid_subpath(selection): if os.path.isdir(selection) and self.allow_folders: if os.path.isdir(selection) and self.state.allow_folders: return True if os.path.isfile(selection) and self.allow_files: if os.path.isfile(selection) and self.state.allow_files: return True return False Loading @@ -102,7 +124,7 @@ class RemoteFileInputModel: if subpath == "": return False for base_path in self.base_paths: for base_path in self.state.base_paths: if subpath.startswith(base_path): return True Loading src/nova/trame/view/components/file_upload.py +27 −13 Original line number Diff line number Diff line """View implementation for FileUpload.""" from typing import Any, List, Optional from typing import Any, List, Tuple, Union from trame.app import get_server from trame.widgets import vuetify3 as vuetify from trame_server.core import State from nova.trame._internal.utils import get_state_param from .remote_file_input import RemoteFileInput Loading @@ -12,25 +16,28 @@ class FileUpload(vuetify.VBtn): def __init__( self, v_model: str, base_paths: Optional[List[str]] = None, v_model: Union[str, Tuple], base_paths: Union[List[str], Tuple, None] = None, extensions: Union[List[str], Tuple, None] = None, label: str = "", return_contents: bool = True, return_contents: Union[bool, Tuple] = True, **kwargs: Any, ) -> None: """Constructor for FileUpload. Parameters ---------- v_model : str v_model : Union[str, Tuple] The state variable to set when the user uploads their file. The state variable will contain a latin1-decoded version of the file contents. If your file is binary or requires a different string encoding, then you can call `encode('latin1')` on the file contents to get the underlying bytes. base_paths: list[str], optional base_paths: Union[List[str], Tuple], optional Passed to :ref:`RemoteFileInput <api_remotefileinput>`. extensions: Union[List[str], Tuple], optional Restricts the files shown to the user to files that end with one of the strings in the list. label : str, optional The text to display on the upload button. return_contents : bool, optional return_contents : Union[bool, Tuple], optional If true, the file contents will be stored in v_model. If false, a file path will be stored in v_model. Defaults to true. **kwargs Loading @@ -41,20 +48,26 @@ class FileUpload(vuetify.VBtn): ------- None """ self._server = get_server(None, client_type="vue3") self._v_model = v_model if base_paths: self._base_paths = base_paths else: self._base_paths = ["/"] self._base_paths = base_paths if base_paths else ["/"] self._extensions = extensions if extensions else [] self._return_contents = return_contents self._ref_name = f"nova__fileupload_{self._next_id}" super().__init__(label, **kwargs) self.create_ui() @property def state(self) -> State: return self._server.state def create_ui(self) -> None: self.local_file_input = vuetify.VFileInput( v_model=(self._v_model, None), v_model=self._v_model, __properties=["accept"], accept=self._extensions, classes="d-none", ref=self._ref_name, # Serialize the content in a way that will work with nova-mvvm and then push it to the server. Loading @@ -67,6 +80,7 @@ class FileUpload(vuetify.VBtn): self.remote_file_input = RemoteFileInput( v_model=self._v_model, base_paths=self._base_paths, extensions=self._extensions, input_props={"classes": "d-none"}, return_contents=self._return_contents, ) Loading @@ -79,7 +93,7 @@ class FileUpload(vuetify.VBtn): @self.server.controller.trigger(f"decode_blob_{self._id}") def _decode_blob(contents: bytes) -> None: self.remote_file_input.decode_file(contents, self._return_contents) self.remote_file_input.decode_file(contents, get_state_param(self.state, self._return_contents)) def select_file(self, value: str) -> None: """Programmatically set the RemoteFileInput path. Loading src/nova/trame/view/components/remote_file_input.py +114 −37 Original line number Diff line number Diff line Loading @@ -2,14 +2,17 @@ from functools import partial from tempfile import NamedTemporaryFile from typing import Any, Optional, Union, cast from typing import Any, List, Optional, Tuple, Union, cast from trame.app import get_server from trame.widgets import client, html from trame.widgets import vuetify3 as vuetify from trame_client.widgets.core import AbstractElement from trame_server.core import State from nova.mvvm._internal.utils import rgetdictvalue from nova.mvvm.trame_binding import TrameBinding from nova.trame._internal.utils import get_state_name, set_state_param from nova.trame.model.remote_file_input import RemoteFileInputModel from nova.trame.view_model.remote_file_input import RemoteFileInputViewModel Loading @@ -24,57 +27,47 @@ class RemoteFileInput: def __init__( self, v_model: Optional[Union[tuple[str, Any], str]] = None, allow_files: bool = True, allow_folders: bool = False, allow_nonexistent_path: bool = False, base_paths: Optional[list[str]] = None, v_model: Union[str, Tuple], allow_files: Union[bool, Tuple] = True, allow_folders: Union[bool, Tuple] = False, base_paths: Union[List[str], Tuple, None] = None, dialog_props: Optional[dict[str, Any]] = None, extensions: Optional[list[str]] = None, extensions: Union[List[str], Tuple, None] = None, input_props: Optional[dict[str, Any]] = None, return_contents: bool = False, return_contents: Union[bool, Tuple] = False, ) -> None: """Constructor for RemoteFileInput. Parameters ---------- v_model : tuple[str, Any] or str, optional v_model : Union[str, Tuple] The v-model for this component. If this references a Pydantic configuration variable, then this component will attempt to load a label, hint, and validation rules from the configuration for you automatically. allow_files : bool allow_files : Union[bool, Tuple], optional If true, the user can save a file selection. allow_folders : bool allow_folders : Union[bool, Tuple], optional If true, the user can save a folder selection. allow_nonexistent_path : bool If false, the user will be warned when they've selected a non-existent path on the filesystem. base_paths : list[str], optional base_paths : Union[List[str], Tuple], optional Only files under these paths will be shown. dialog_props : dict[str, typing.Any], optional dialog_props : Dict[str, typing.Any], optional Props to be passed to VDialog. extensions : list[str], optional extensions : Union[List[str], Tuple], optional Only files with these extensions will be shown by default. The user can still choose to view all files. input_props : dict[str, typing.Any], optional input_props : Dict[str, typing.Any], optional Props to be passed to InputField. return_contents : bool return_contents : Union[bool, Tuple], optional If true, then the v_model will contain the contents of the file. If false, then the v_model will contain the path of the file. Raises ------ ValueError If v_model is None. path of the file. Defaults to false. Returns ------- None """ if v_model is None: raise ValueError("RemoteFileInput must have a v_model attribute.") self.server = get_server(None, client_type="vue3") self.v_model = v_model self.allow_files = allow_files self.allow_folders = allow_folders self.allow_nonexistent_path = allow_nonexistent_path self.base_paths = base_paths if base_paths else ["/"] self.dialog_props = dict(dialog_props) if dialog_props else {} self.extensions = extensions if extensions else [] Loading @@ -88,10 +81,16 @@ class RemoteFileInput: if "width" not in self.dialog_props: self.dialog_props["width"] = 600 self.create_model() self.create_viewmodel() self._create_model() self._create_viewmodel() self._setup_bindings() self.create_ui() @property def state(self) -> State: return self.server.state def create_ui(self) -> None: with cast( AbstractElement, Loading Loading @@ -175,12 +174,11 @@ class RemoteFileInput: click=partial(self.vm.close_dialog, cancel=True), ) def create_model(self) -> None: self.model = RemoteFileInputModel(self.allow_files, self.allow_folders, self.base_paths, self.extensions) def _create_model(self) -> None: self.model = RemoteFileInputModel() def create_viewmodel(self) -> None: server = get_server(None, client_type="vue3") binding = TrameBinding(server.state) def _create_viewmodel(self) -> None: binding = TrameBinding(self.state) if isinstance(self.v_model, tuple): model_name = self.v_model[0] Loading @@ -197,12 +195,91 @@ class RemoteFileInput: self.vm.file_list_bind.connect(self.vm.get_file_list_state_name()) self.vm.filter_bind.connect(self.vm.get_filter_state_name()) self.vm.on_close_bind.connect(client.JSEval(exec=f"{self.vm.get_dialog_state_name()} = false;").exec) if self.return_contents: self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name()) self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name()) # This method sets up Trame state change listeners for each binding parameter that can be changed directly by this # component. This allows us to communicate the changes to the developer's bindings without requiring our own. We # don't want bindings in the internal implementation as our callbacks could compete with the developer's. def _setup_bindings(self) -> None: # If the bindings were given initial values, write these to the state. self._last_allow_files = set_state_param(self.state, self.allow_files) self._last_allow_folders = set_state_param(self.state, self.allow_folders) self._last_base_paths = set_state_param(self.state, self.base_paths) self._last_extensions = set_state_param(self.state, self.extensions) self._last_return_contents = set_state_param(self.state, self.return_contents) # Now we need to propagate the state to this component's view model. self.vm.set_binding_parameters( allow_files=self.allow_files, allow_folders=self.allow_folders, base_paths=self.base_paths, extensions=self.extensions, ) self._setup_update_binding(self._last_return_contents) # Now we set up the change listeners for all bound parameters. These are responsible for updating the component # when other portions of the application manipulate these parameters. if isinstance(self.allow_files, tuple): @self.state.change(get_state_name(self.allow_files[0])) def on_allow_files_change(**kwargs: Any) -> None: if isinstance(self.allow_files, bool): return allow_files = rgetdictvalue(kwargs, self.allow_files[0]) if allow_files != self._last_allow_files: self.vm.set_binding_parameters( allow_files=set_state_param(self.state, self.allow_files, allow_files) ) if isinstance(self.allow_folders, tuple): @self.state.change(get_state_name(self.allow_folders[0])) def on_allow_folders_change(**kwargs: Any) -> None: if isinstance(self.allow_folders, bool): return allow_folders = rgetdictvalue(kwargs, self.allow_folders[0]) if allow_folders != self._last_allow_folders: self.vm.set_binding_parameters( allow_folders=set_state_param(self.state, self.allow_folders, allow_folders) ) if isinstance(self.base_paths, tuple): @self.state.change(get_state_name(self.base_paths[0])) def on_base_paths_change(**kwargs: Any) -> None: if isinstance(self.base_paths, bool): return base_paths = rgetdictvalue(kwargs, self.base_paths[0]) if base_paths != self._last_base_paths: self.vm.set_binding_parameters(base_paths=set_state_param(self.state, self.base_paths, base_paths)) if isinstance(self.extensions, tuple): @self.state.change(get_state_name(self.extensions[0])) def on_extensions_change(**kwargs: Any) -> None: if isinstance(self.extensions, bool): return extensions = rgetdictvalue(kwargs, self.extensions[0]) if extensions != self._last_extensions: self.vm.set_binding_parameters(extensions=set_state_param(self.state, self.extensions, extensions)) if isinstance(self.return_contents, tuple): @self.state.change(get_state_name(self.return_contents[0])) def on_return_contents_change(**kwargs: Any) -> None: if isinstance(self.return_contents, bool): return return_contents = rgetdictvalue(kwargs, self.return_contents[0]) if return_contents != self._last_return_contents: self._setup_update_binding(return_contents) def _setup_update_binding(self, read_file: bool) -> None: self.vm.reset_update_binding() if read_file: self.vm.on_update_bind.connect(self.read_file) else: self.vm.on_update_bind.connect(self.set_v_model) self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name()) self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name()) def read_file(self, file_path: str) -> None: with open(file_path, mode="rb") as file: Loading src/nova/trame/view_model/remote_file_input.py +14 −7 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ class RemoteFileInputViewModel: def __init__(self, model: RemoteFileInputModel, binding: BindingInterface) -> None: """Creates a new RemoteFileInputViewModel.""" self.model = model self.binding = binding # Needed to keep state variables separated if this class is instantiated multiple times. self.id = RemoteFileInputViewModel.counter Loading @@ -23,13 +24,16 @@ class RemoteFileInputViewModel: self.showing_base_paths = True self.previous_value = "" self.value = "" self.dialog_bind = binding.new_bind() self.file_list_bind = binding.new_bind() self.filter_bind = binding.new_bind() self.showing_all_bind = binding.new_bind() self.valid_selection_bind = binding.new_bind() self.on_close_bind = binding.new_bind() self.on_update_bind = binding.new_bind() self.dialog_bind = self.binding.new_bind() self.file_list_bind = self.binding.new_bind() self.filter_bind = self.binding.new_bind() self.showing_all_bind = self.binding.new_bind() self.valid_selection_bind = self.binding.new_bind() self.on_close_bind = self.binding.new_bind() self.on_update_bind = self.binding.new_bind() def reset_update_binding(self) -> None: self.on_update_bind = self.binding.new_bind() def open_dialog(self) -> None: self.previous_value = self.value Loading Loading @@ -93,3 +97,6 @@ class RemoteFileInputViewModel: self.valid_selection_bind.update_in_view(self.model.valid_selection(new_path)) self.populate_file_list() def set_binding_parameters(self, **kwargs: Any) -> None: self.model.set_binding_parameters(**kwargs) tests/gallery/models/file_upload.py +4 −0 Original line number Diff line number Diff line """Model for MVVM demo of FileUpload.""" from typing import List from pydantic import BaseModel, Field class FileUploadState(BaseModel): """Model for MVVM demo of FileUpload.""" extensions: List[str] = Field(default=[".cif", ".nxs"]) file: str = Field(default="") label: str = Field(default="Upload File") Loading
src/nova/trame/model/remote_file_input.py +35 −13 Original line number Diff line number Diff line Loading @@ -3,21 +3,39 @@ import os from functools import cmp_to_key from locale import strcoll from typing import Any, Union from typing import Any, List, Union from pydantic import BaseModel, Field class RemoteFileInputState(BaseModel): """Pydantic model for RemoteFileInput state.""" allow_files: bool = Field(default=False) allow_folders: bool = Field(default=False) base_paths: List[str] = Field(default=[]) extensions: List[str] = Field(default=[]) class RemoteFileInputModel: """Manages interactions between RemoteFileInput and the file system.""" def __init__(self, allow_files: bool, allow_folders: bool, base_paths: list[str], extensions: list[str]) -> None: def __init__(self) -> None: """Creates a new RemoteFileInputModel.""" self.allow_files = allow_files self.allow_folders = allow_folders self.base_paths = base_paths self.extensions = extensions self.state = RemoteFileInputState() def set_binding_parameters(self, **kwargs: Any) -> None: if "allow_files" in kwargs: self.state.allow_files = kwargs["allow_files"] if "allow_folders" in kwargs: self.state.allow_folders = kwargs["allow_folders"] if "base_paths" in kwargs: self.state.base_paths = kwargs["base_paths"] if "extensions" in kwargs: self.state.extensions = kwargs["extensions"] def get_base_paths(self) -> list[dict[str, Any]]: return [{"path": base_path, "directory": True} for base_path in self.base_paths] return [{"path": base_path, "directory": True} for base_path in self.state.base_paths] def scan_current_path( self, current_path: str, showing_all_files: bool, filter: str Loading Loading @@ -72,7 +90,7 @@ class RemoteFileInputModel: if not showing_base_paths and file != "..": return os.path.join(old_path, file) elif not showing_base_paths: if old_path in self.base_paths: if old_path in self.state.base_paths: return "" else: return os.path.dirname(old_path) Loading @@ -83,17 +101,21 @@ class RemoteFileInputModel: if entry.is_dir(): return True if not self.allow_files: if not self.state.allow_files: return False return showing_all_files or not self.extensions or any(entry.name.endswith(ext) for ext in self.extensions) return ( showing_all_files or not self.state.extensions or any(entry.name.endswith(ext) for ext in self.state.extensions) ) def valid_selection(self, selection: str) -> bool: if self.valid_subpath(selection): if os.path.isdir(selection) and self.allow_folders: if os.path.isdir(selection) and self.state.allow_folders: return True if os.path.isfile(selection) and self.allow_files: if os.path.isfile(selection) and self.state.allow_files: return True return False Loading @@ -102,7 +124,7 @@ class RemoteFileInputModel: if subpath == "": return False for base_path in self.base_paths: for base_path in self.state.base_paths: if subpath.startswith(base_path): return True Loading
src/nova/trame/view/components/file_upload.py +27 −13 Original line number Diff line number Diff line """View implementation for FileUpload.""" from typing import Any, List, Optional from typing import Any, List, Tuple, Union from trame.app import get_server from trame.widgets import vuetify3 as vuetify from trame_server.core import State from nova.trame._internal.utils import get_state_param from .remote_file_input import RemoteFileInput Loading @@ -12,25 +16,28 @@ class FileUpload(vuetify.VBtn): def __init__( self, v_model: str, base_paths: Optional[List[str]] = None, v_model: Union[str, Tuple], base_paths: Union[List[str], Tuple, None] = None, extensions: Union[List[str], Tuple, None] = None, label: str = "", return_contents: bool = True, return_contents: Union[bool, Tuple] = True, **kwargs: Any, ) -> None: """Constructor for FileUpload. Parameters ---------- v_model : str v_model : Union[str, Tuple] The state variable to set when the user uploads their file. The state variable will contain a latin1-decoded version of the file contents. If your file is binary or requires a different string encoding, then you can call `encode('latin1')` on the file contents to get the underlying bytes. base_paths: list[str], optional base_paths: Union[List[str], Tuple], optional Passed to :ref:`RemoteFileInput <api_remotefileinput>`. extensions: Union[List[str], Tuple], optional Restricts the files shown to the user to files that end with one of the strings in the list. label : str, optional The text to display on the upload button. return_contents : bool, optional return_contents : Union[bool, Tuple], optional If true, the file contents will be stored in v_model. If false, a file path will be stored in v_model. Defaults to true. **kwargs Loading @@ -41,20 +48,26 @@ class FileUpload(vuetify.VBtn): ------- None """ self._server = get_server(None, client_type="vue3") self._v_model = v_model if base_paths: self._base_paths = base_paths else: self._base_paths = ["/"] self._base_paths = base_paths if base_paths else ["/"] self._extensions = extensions if extensions else [] self._return_contents = return_contents self._ref_name = f"nova__fileupload_{self._next_id}" super().__init__(label, **kwargs) self.create_ui() @property def state(self) -> State: return self._server.state def create_ui(self) -> None: self.local_file_input = vuetify.VFileInput( v_model=(self._v_model, None), v_model=self._v_model, __properties=["accept"], accept=self._extensions, classes="d-none", ref=self._ref_name, # Serialize the content in a way that will work with nova-mvvm and then push it to the server. Loading @@ -67,6 +80,7 @@ class FileUpload(vuetify.VBtn): self.remote_file_input = RemoteFileInput( v_model=self._v_model, base_paths=self._base_paths, extensions=self._extensions, input_props={"classes": "d-none"}, return_contents=self._return_contents, ) Loading @@ -79,7 +93,7 @@ class FileUpload(vuetify.VBtn): @self.server.controller.trigger(f"decode_blob_{self._id}") def _decode_blob(contents: bytes) -> None: self.remote_file_input.decode_file(contents, self._return_contents) self.remote_file_input.decode_file(contents, get_state_param(self.state, self._return_contents)) def select_file(self, value: str) -> None: """Programmatically set the RemoteFileInput path. Loading
src/nova/trame/view/components/remote_file_input.py +114 −37 Original line number Diff line number Diff line Loading @@ -2,14 +2,17 @@ from functools import partial from tempfile import NamedTemporaryFile from typing import Any, Optional, Union, cast from typing import Any, List, Optional, Tuple, Union, cast from trame.app import get_server from trame.widgets import client, html from trame.widgets import vuetify3 as vuetify from trame_client.widgets.core import AbstractElement from trame_server.core import State from nova.mvvm._internal.utils import rgetdictvalue from nova.mvvm.trame_binding import TrameBinding from nova.trame._internal.utils import get_state_name, set_state_param from nova.trame.model.remote_file_input import RemoteFileInputModel from nova.trame.view_model.remote_file_input import RemoteFileInputViewModel Loading @@ -24,57 +27,47 @@ class RemoteFileInput: def __init__( self, v_model: Optional[Union[tuple[str, Any], str]] = None, allow_files: bool = True, allow_folders: bool = False, allow_nonexistent_path: bool = False, base_paths: Optional[list[str]] = None, v_model: Union[str, Tuple], allow_files: Union[bool, Tuple] = True, allow_folders: Union[bool, Tuple] = False, base_paths: Union[List[str], Tuple, None] = None, dialog_props: Optional[dict[str, Any]] = None, extensions: Optional[list[str]] = None, extensions: Union[List[str], Tuple, None] = None, input_props: Optional[dict[str, Any]] = None, return_contents: bool = False, return_contents: Union[bool, Tuple] = False, ) -> None: """Constructor for RemoteFileInput. Parameters ---------- v_model : tuple[str, Any] or str, optional v_model : Union[str, Tuple] The v-model for this component. If this references a Pydantic configuration variable, then this component will attempt to load a label, hint, and validation rules from the configuration for you automatically. allow_files : bool allow_files : Union[bool, Tuple], optional If true, the user can save a file selection. allow_folders : bool allow_folders : Union[bool, Tuple], optional If true, the user can save a folder selection. allow_nonexistent_path : bool If false, the user will be warned when they've selected a non-existent path on the filesystem. base_paths : list[str], optional base_paths : Union[List[str], Tuple], optional Only files under these paths will be shown. dialog_props : dict[str, typing.Any], optional dialog_props : Dict[str, typing.Any], optional Props to be passed to VDialog. extensions : list[str], optional extensions : Union[List[str], Tuple], optional Only files with these extensions will be shown by default. The user can still choose to view all files. input_props : dict[str, typing.Any], optional input_props : Dict[str, typing.Any], optional Props to be passed to InputField. return_contents : bool return_contents : Union[bool, Tuple], optional If true, then the v_model will contain the contents of the file. If false, then the v_model will contain the path of the file. Raises ------ ValueError If v_model is None. path of the file. Defaults to false. Returns ------- None """ if v_model is None: raise ValueError("RemoteFileInput must have a v_model attribute.") self.server = get_server(None, client_type="vue3") self.v_model = v_model self.allow_files = allow_files self.allow_folders = allow_folders self.allow_nonexistent_path = allow_nonexistent_path self.base_paths = base_paths if base_paths else ["/"] self.dialog_props = dict(dialog_props) if dialog_props else {} self.extensions = extensions if extensions else [] Loading @@ -88,10 +81,16 @@ class RemoteFileInput: if "width" not in self.dialog_props: self.dialog_props["width"] = 600 self.create_model() self.create_viewmodel() self._create_model() self._create_viewmodel() self._setup_bindings() self.create_ui() @property def state(self) -> State: return self.server.state def create_ui(self) -> None: with cast( AbstractElement, Loading Loading @@ -175,12 +174,11 @@ class RemoteFileInput: click=partial(self.vm.close_dialog, cancel=True), ) def create_model(self) -> None: self.model = RemoteFileInputModel(self.allow_files, self.allow_folders, self.base_paths, self.extensions) def _create_model(self) -> None: self.model = RemoteFileInputModel() def create_viewmodel(self) -> None: server = get_server(None, client_type="vue3") binding = TrameBinding(server.state) def _create_viewmodel(self) -> None: binding = TrameBinding(self.state) if isinstance(self.v_model, tuple): model_name = self.v_model[0] Loading @@ -197,12 +195,91 @@ class RemoteFileInput: self.vm.file_list_bind.connect(self.vm.get_file_list_state_name()) self.vm.filter_bind.connect(self.vm.get_filter_state_name()) self.vm.on_close_bind.connect(client.JSEval(exec=f"{self.vm.get_dialog_state_name()} = false;").exec) if self.return_contents: self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name()) self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name()) # This method sets up Trame state change listeners for each binding parameter that can be changed directly by this # component. This allows us to communicate the changes to the developer's bindings without requiring our own. We # don't want bindings in the internal implementation as our callbacks could compete with the developer's. def _setup_bindings(self) -> None: # If the bindings were given initial values, write these to the state. self._last_allow_files = set_state_param(self.state, self.allow_files) self._last_allow_folders = set_state_param(self.state, self.allow_folders) self._last_base_paths = set_state_param(self.state, self.base_paths) self._last_extensions = set_state_param(self.state, self.extensions) self._last_return_contents = set_state_param(self.state, self.return_contents) # Now we need to propagate the state to this component's view model. self.vm.set_binding_parameters( allow_files=self.allow_files, allow_folders=self.allow_folders, base_paths=self.base_paths, extensions=self.extensions, ) self._setup_update_binding(self._last_return_contents) # Now we set up the change listeners for all bound parameters. These are responsible for updating the component # when other portions of the application manipulate these parameters. if isinstance(self.allow_files, tuple): @self.state.change(get_state_name(self.allow_files[0])) def on_allow_files_change(**kwargs: Any) -> None: if isinstance(self.allow_files, bool): return allow_files = rgetdictvalue(kwargs, self.allow_files[0]) if allow_files != self._last_allow_files: self.vm.set_binding_parameters( allow_files=set_state_param(self.state, self.allow_files, allow_files) ) if isinstance(self.allow_folders, tuple): @self.state.change(get_state_name(self.allow_folders[0])) def on_allow_folders_change(**kwargs: Any) -> None: if isinstance(self.allow_folders, bool): return allow_folders = rgetdictvalue(kwargs, self.allow_folders[0]) if allow_folders != self._last_allow_folders: self.vm.set_binding_parameters( allow_folders=set_state_param(self.state, self.allow_folders, allow_folders) ) if isinstance(self.base_paths, tuple): @self.state.change(get_state_name(self.base_paths[0])) def on_base_paths_change(**kwargs: Any) -> None: if isinstance(self.base_paths, bool): return base_paths = rgetdictvalue(kwargs, self.base_paths[0]) if base_paths != self._last_base_paths: self.vm.set_binding_parameters(base_paths=set_state_param(self.state, self.base_paths, base_paths)) if isinstance(self.extensions, tuple): @self.state.change(get_state_name(self.extensions[0])) def on_extensions_change(**kwargs: Any) -> None: if isinstance(self.extensions, bool): return extensions = rgetdictvalue(kwargs, self.extensions[0]) if extensions != self._last_extensions: self.vm.set_binding_parameters(extensions=set_state_param(self.state, self.extensions, extensions)) if isinstance(self.return_contents, tuple): @self.state.change(get_state_name(self.return_contents[0])) def on_return_contents_change(**kwargs: Any) -> None: if isinstance(self.return_contents, bool): return return_contents = rgetdictvalue(kwargs, self.return_contents[0]) if return_contents != self._last_return_contents: self._setup_update_binding(return_contents) def _setup_update_binding(self, read_file: bool) -> None: self.vm.reset_update_binding() if read_file: self.vm.on_update_bind.connect(self.read_file) else: self.vm.on_update_bind.connect(self.set_v_model) self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name()) self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name()) def read_file(self, file_path: str) -> None: with open(file_path, mode="rb") as file: Loading
src/nova/trame/view_model/remote_file_input.py +14 −7 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ class RemoteFileInputViewModel: def __init__(self, model: RemoteFileInputModel, binding: BindingInterface) -> None: """Creates a new RemoteFileInputViewModel.""" self.model = model self.binding = binding # Needed to keep state variables separated if this class is instantiated multiple times. self.id = RemoteFileInputViewModel.counter Loading @@ -23,13 +24,16 @@ class RemoteFileInputViewModel: self.showing_base_paths = True self.previous_value = "" self.value = "" self.dialog_bind = binding.new_bind() self.file_list_bind = binding.new_bind() self.filter_bind = binding.new_bind() self.showing_all_bind = binding.new_bind() self.valid_selection_bind = binding.new_bind() self.on_close_bind = binding.new_bind() self.on_update_bind = binding.new_bind() self.dialog_bind = self.binding.new_bind() self.file_list_bind = self.binding.new_bind() self.filter_bind = self.binding.new_bind() self.showing_all_bind = self.binding.new_bind() self.valid_selection_bind = self.binding.new_bind() self.on_close_bind = self.binding.new_bind() self.on_update_bind = self.binding.new_bind() def reset_update_binding(self) -> None: self.on_update_bind = self.binding.new_bind() def open_dialog(self) -> None: self.previous_value = self.value Loading Loading @@ -93,3 +97,6 @@ class RemoteFileInputViewModel: self.valid_selection_bind.update_in_view(self.model.valid_selection(new_path)) self.populate_file_list() def set_binding_parameters(self, **kwargs: Any) -> None: self.model.set_binding_parameters(**kwargs)
tests/gallery/models/file_upload.py +4 −0 Original line number Diff line number Diff line """Model for MVVM demo of FileUpload.""" from typing import List from pydantic import BaseModel, Field class FileUploadState(BaseModel): """Model for MVVM demo of FileUpload.""" extensions: List[str] = Field(default=[".cif", ".nxs"]) file: str = Field(default="") label: str = Field(default="Upload File")