diff --git a/aiidalab_widgets_base/computational_resources.py b/aiidalab_widgets_base/computational_resources.py index b7bd12e73..819c89daa 100644 --- a/aiidalab_widgets_base/computational_resources.py +++ b/aiidalab_widgets_base/computational_resources.py @@ -159,7 +159,7 @@ def refresh(self, _=None): with self.hold_trait_notifications(): self.code_select_dropdown.options = self._get_codes() if not self.code_select_dropdown.options: - self.output.value = f"No codes found for default calcjob plugin '{self.default_calc_job_plugin}'." + self.output.value = f"No codes found for default calcjob plugin {self.default_calc_job_plugin!r}." self.code_select_dropdown.disabled = True else: self.code_select_dropdown.disabled = False diff --git a/aiidalab_widgets_base/elns.py b/aiidalab_widgets_base/elns.py index 99f2d63aa..64e27f234 100644 --- a/aiidalab_widgets_base/elns.py +++ b/aiidalab_widgets_base/elns.py @@ -23,7 +23,7 @@ def connect_to_eln(eln_instance=None, **kwargs): except (FileNotFoundError, json.JSONDecodeError, KeyError): return ( None, - f"Can't open '{ELN_CONFIG}' (ELN configuration file). Instance: {eln_instance}", + f"Can't open {ELN_CONFIG!r} (ELN configuration file). Instance: {eln_instance}", ) # If no ELN instance was specified, trying the default one. @@ -35,7 +35,7 @@ def connect_to_eln(eln_instance=None, **kwargs): eln_config = config[eln_instance] eln_type = eln_config.pop("eln_type", None) else: # The selected instance is not present in the config. - return None, f"Didn't find configuration for the '{eln_instance}' instance." + return None, f"Didn't find configuration for the {eln_instance!r} instance." # If the ELN type cannot be identified - aborting. if not eln_type: @@ -73,7 +73,7 @@ def __init__(self, path_to_root="../", **kwargs): if eln is None: url = f"{path_to_root}aiidalab-widgets-base/notebooks/eln_configure.ipynb" - error_message.value = f"""Warning! The access to ELN is not configured. Please follow the link to configure it.
More details: {msg}""" + error_message.value = f"""Warning! The access to ELN is not configured. Please follow the link to configure it.
More details: {msg!r}""" return tl.dlink((eln, "node"), (self, "node")) diff --git a/aiidalab_widgets_base/nodes.py b/aiidalab_widgets_base/nodes.py index 770d7caf1..371f4953f 100644 --- a/aiidalab_widgets_base/nodes.py +++ b/aiidalab_widgets_base/nodes.py @@ -308,7 +308,7 @@ def to_html_string(self): return f""" - +

{self.description}

diff --git a/aiidalab_widgets_base/structures.py b/aiidalab_widgets_base/structures.py index 48688d2bc..df19c1fd5 100644 --- a/aiidalab_widgets_base/structures.py +++ b/aiidalab_widgets_base/structures.py @@ -1,5 +1,4 @@ """Module to provide functionality to import structures.""" - import datetime import functools import io @@ -14,7 +13,7 @@ from aiida import engine, orm, plugins # Local imports -from .data import LigandSelectorWidget +from .data import FunctionalGroupSelectorWidget from .utils import StatusHTML, exceptions, get_ase_from_file, get_formula from .viewers import StructureDataViewer @@ -39,9 +38,7 @@ class StructureManagerWidget(ipw.VBox): input_structure = tl.Union( [tl.Instance(ase.Atoms), tl.Instance(orm.Data)], allow_none=True ) - structure = tl.Union( - [tl.Instance(ase.Atoms), tl.Instance(orm.Data)], allow_none=True - ) + structure = tl.Instance(ase.Atoms, allow_none=True) structure_node = tl.Instance(orm.Data, allow_none=True, read_only=True) node_class = tl.Unicode() @@ -84,8 +81,8 @@ def __init__( if viewer: self.viewer = viewer else: - self.viewer = StructureDataViewer(**kwargs) - tl.dlink((self, "structure_node"), (self.viewer, "structure")) + self.viewer = StructureDataViewer() + tl.dlink((self, "structure"), (self.viewer, "structure")) # Store button. self.btn_store = ipw.Button(description="Store in AiiDA", disabled=True) @@ -184,6 +181,10 @@ def _structure_editors(self, editors): for i, editor in enumerate(editors): editors_tab.set_title(i, editor.title) tl.link((editor, "structure"), (self, "structure")) + if editor.has_trait("input_selection"): + tl.dlink( + (editor, "input_selection"), (self.viewer, "input_selection") + ) if editor.has_trait("selection"): tl.link((editor, "selection"), (self.viewer, "selection")) if editor.has_trait("camera_orientation"): @@ -336,6 +337,7 @@ def _structure_changed(self, change=None): This function enables/disables `btn_store` widget if structure is provided/set to None. Also, the function sets `structure_node` trait to the selected node type. """ + if not self.structure_set_by_undo: self.history.append(change["new"]) @@ -1051,7 +1053,7 @@ def disable_element(_=None): self.element.disabled = True # Ligand selection. - self.ligand = LigandSelectorWidget() + self.ligand = FunctionalGroupSelectorWidget() self.ligand.observe(disable_element, names="value") # Add atom. @@ -1262,6 +1264,8 @@ def translate_dr(self, _=None, atoms=None, selection=None): self.action_vector * self.displacement.value ) + self.input_selection = None # Clear selection. + self.structure, self.input_selection = atoms, selection @_register_structure @@ -1271,7 +1275,7 @@ def translate_dxdydz(self, _=None, atoms=None, selection=None): # The action. atoms.positions[self.selection] += np.array(self.str2vec(self.dxyz.value)) - + self.input_selection = None # Clear selection. self.structure, self.input_selection = atoms, selection @_register_structure @@ -1281,7 +1285,7 @@ def translate_to_xyz(self, _=None, atoms=None, selection=None): # The action. geo_center = np.average(self.structure[self.selection].get_positions(), axis=0) atoms.positions[self.selection] += self.str2vec(self.dxyz.value) - geo_center - + self.input_selection = None # Clear selection. self.structure, self.input_selection = atoms, selection @_register_structure @@ -1295,6 +1299,7 @@ def rotate(self, _=None, atoms=None, selection=None): center = self.str2vec(self.point.value) rotated_subset.rotate(self.phi.value, v=vec, center=center, rotate_cell=False) atoms.positions[self.selection] = rotated_subset.positions + self.input_selection = None # Clear selection. self.structure, self.input_selection = atoms, selection @@ -1329,6 +1334,8 @@ def mirror(self, _=None, norm=None, point=None, atoms=None, selection=None): # Mirror atoms. atoms.positions[selection] -= 2 * projections + self.input_selection = None # Clear selection. + self.structure, self.input_selection = atoms, selection def mirror_3p(self, _=None): @@ -1375,6 +1382,7 @@ def mod_element(self, _=None, atoms=None, selection=None): initial_ligand = self.ligand.rotate( align_to=self.action_vector, remove_anchor=True ) + for idx in self.selection: position = self.structure.positions[idx].copy() lgnd = initial_ligand.copy() @@ -1390,6 +1398,7 @@ def mod_element(self, _=None, atoms=None, selection=None): @_register_selection def copy_sel(self, _=None, atoms=None, selection=None): """Copy selected atoms and shift by 1.0 A along X-axis.""" + last_atom = atoms.get_global_number_of_atoms() # The action @@ -1397,15 +1406,15 @@ def copy_sel(self, _=None, atoms=None, selection=None): add_atoms.translate([1.0, 0, 0]) atoms += add_atoms - new_selection = list(range(last_atom, last_atom + len(selection))) - self.structure, self.input_selection = atoms, new_selection + self.structure, self.input_selection = atoms, list( + range(last_atom, last_atom + len(selection)) + ) @_register_structure @_register_selection def add(self, _=None, atoms=None, selection=None): """Add atoms.""" last_atom = atoms.get_global_number_of_atoms() - if self.ligand.value == 0: initial_ligand = ase.Atoms([ase.Atom(self.element.value, [0, 0, 0])]) rad = SYMBOL_RADIUS[self.element.value] @@ -1431,6 +1440,8 @@ def add(self, _=None, atoms=None, selection=None): new_selection = list(range(last_atom, last_atom + len(selection) * len(lgnd))) + # The order of the traitlets below is important - + # we must be sure trait atoms is set before trait selection self.structure, self.input_selection = atoms, new_selection @_register_structure @@ -1439,6 +1450,7 @@ def remove(self, _=None, atoms=None, selection=None): """Remove selected atoms.""" del [atoms[selection]] + # The order of the traitlets below is important - + # we must be sure trait atoms is set before trait selection self.structure = atoms self.input_selection = None - self.input_selection = [] diff --git a/aiidalab_widgets_base/utils/__init__.py b/aiidalab_widgets_base/utils/__init__.py index 4b8b0b370..b8aa8565a 100644 --- a/aiidalab_widgets_base/utils/__init__.py +++ b/aiidalab_widgets_base/utils/__init__.py @@ -34,7 +34,7 @@ def predefine_settings(obj, **kwargs): if hasattr(obj, key): setattr(obj, key, value) else: - raise AttributeError(f"'{obj}' object has no attribute '{key}'") + raise AttributeError(f"{obj!r} object has no attribute {key!r}") def get_ase_from_file(fname, file_format=None): # pylint: disable=redefined-builtin diff --git a/aiidalab_widgets_base/utils/exceptions.py b/aiidalab_widgets_base/utils/exceptions.py index 452419c76..148928670 100644 --- a/aiidalab_widgets_base/utils/exceptions.py +++ b/aiidalab_widgets_base/utils/exceptions.py @@ -3,5 +3,5 @@ class ListOrTuppleError(TypeError): def __init__(self, value): super().__init__( - f"The provided value '{value}' is not a list or a tupple, but a {type(value)}." + f"The provided value {value!r} is not a list or a tupple, but a {type(value)}." ) diff --git a/aiidalab_widgets_base/viewers.py b/aiidalab_widgets_base/viewers.py index 77de9f98e..88dd987bc 100644 --- a/aiidalab_widgets_base/viewers.py +++ b/aiidalab_widgets_base/viewers.py @@ -1,15 +1,18 @@ +from __future__ import annotations + """Jupyter viewers for AiiDA data objects.""" # pylint: disable=no-self-use import base64 +import copy import re import warnings -from copy import deepcopy import ase import ipywidgets as ipw import nglview import numpy as np +import shortuuid import spglib import traitlets as tl import vapory @@ -123,18 +126,148 @@ def __init__(self, parameter, downloadable=True, **kwargs): super().__init__([self.widget], **kwargs) +class NglViewerRepresentation(ipw.HBox): + """This class represents the parameters for displaying a structure in NGLViewer. + + It is utilized in the structure viewer, where multiple representations can be defined, + each specifying how to visually represent a particular subset of atoms. + """ + + viewer_class = None # The structure viewer class that contains this representation. + + def __init__(self, uuid, indices=None, deletable=True, atom_show_threshold=1): + """Initialize the representation. + + uuid: str + Unique identifier for the representation. + indices: list + List of indices to be displayed. + deletable: bool + If True, add a button to delete the representation. + atom_show_threshold: int + only show the atom if the corresponding value in the representation array is larger or equal than this threshold. + """ + + self.atom_show_threshold = atom_show_threshold + self.uuid = uuid + + self.show = ipw.Checkbox( + value=True, + layout={"width": "40px"}, + style={"description_width": "0px"}, + disabled=False, + ) + + self.selection = ipw.Text( + value=list_to_string_range(indices, shift=1) if indices is not None else "", + layout={"width": "80px"}, + style={"description_width": "0px"}, + ) + self.type = ipw.Dropdown( + options=["ball+stick", "spacefill"], + value="ball+stick", + disabled=False, + layout={"width": "100px"}, + style={"description_width": "0px"}, + ) + self.size = ipw.FloatText( + value=3, + layout={"width": "40px"}, + style={"description_width": "0px"}, + ) + self.color = ipw.Dropdown( + options=["element", "red", "green", "blue", "yellow", "orange", "purple"], + value="element", + disabled=False, + layout={"width": "80px"}, + style={"description_width": "initial"}, + ) + + # Delete button. + self.delete_button = ipw.Button( + description="", + icon="trash", + button_style="danger", + layout={ + "width": "50px", + "visibility": "visible" if deletable else "hidden", + }, + ) + self.delete_button.on_click(self.delete_myself) + + super().__init__( + children=[ + self.show, + self.selection, + self.type, + self.size, + self.color, + self.delete_button, + ] + ) + + def delete_myself(self, _): + self.viewer_class.delete_representation(self) + + def sync_myself_to_array_from_atoms_object(self, structure: ase.Atoms | None): + """Update representation from the structure object.""" + if structure: + if self.uuid in structure.arrays: + self.selection.value = list_to_string_range( + np.where(self.atoms_in_representaion(structure))[0], shift=1 + ) + + def add_myself_to_atoms_object(self, structure: ase.Atoms | None): + """Add representation array to the structure object. If the array already exists, update it.""" + if structure: + array_representation = np.full(len(structure), -1, dtype=int) + selection = np.array( + string_range_to_list(self.selection.value, shift=-1)[0], dtype=int + ) + # Only attempt to display the existing atoms. + array_representation[selection[selection < len(structure)]] = 1 + structure.set_array(self.uuid, array_representation) + + def atoms_in_representaion(self, structure: ase.Atoms | None = None): + """Return an array of booleans indicating which atoms are present in the representation.""" + if structure: + if self.uuid in structure.arrays: + return structure.arrays[self.uuid] >= self.atom_show_threshold + return None + + def nglview_parameters(self, indices): + """Return the parameters dictionary of a representation.""" + nglview_parameters_dict = { + "type": self.type.value, + "params": { + "sele": "@" + ",".join(map(str, indices)) + if len(indices) > 0 + else "none", + "opacity": 1, + "color": self.color.value, + }, + } + if self.type.value == "ball+stick": + nglview_parameters_dict["params"]["aspectRatio"] = self.size.value + else: + nglview_parameters_dict["params"]["radiusScale"] = 0.1 * self.size.value + + return nglview_parameters_dict + + class _StructureDataBaseViewer(ipw.VBox): """Base viewer class for AiiDA structure or trajectory objects. - :param configure_view: If True, add configuration tabs (deprecated) - :type configure_view: bool - :param configuration_tabs: List of configuration tabs (default: ["Selection", "Appearance", "Cell", "Download"]) - :type configure_view: list - :param default_camera: default camera (orthographic|perspective), can be changed in the Appearance tab - :type default_camera: string - + Traits: + _all_representations: list, containing all the representations of the structure. + input_selection: list used mostly by external tools to populate the selection field. + selection: list of currently selected atoms. + displayed_selection: list of currently displayed atoms in the displayed structure, which also includes super-cell. + supercell: list of supercell dimensions. + cell: ase.cell.Cell object. """ + _all_representations = tl.List() input_selection = tl.List(tl.Int(), allow_none=True) selection = tl.List(tl.Int()) displayed_selection = tl.List(tl.Int()) @@ -143,6 +276,8 @@ class _StructureDataBaseViewer(ipw.VBox): DEFAULT_SELECTION_OPACITY = 0.2 DEFAULT_SELECTION_RADIUS = 6 DEFAULT_SELECTION_COLOR = "green" + REPRESENTATION_PREFIX = "_aiidalab_viewer_representation_" + DEFAULT_REPRESENTATION = "_aiidalab_viewer_representation_default" def __init__( self, @@ -151,6 +286,12 @@ def __init__( default_camera="orthographic", **kwargs, ): + """Initialize the viewer. + + :param configure_view: If True, add configuration tabs (deprecated). + :param configuration_tabs: List of configuration tabs (default: ["Selection", "Appearance", "Cell", "Download"]). + :param default_camera: default camera (orthographic|perspective), can be changed in the Appearance tab. + """ # Defining viewer box. # Nglviwer @@ -222,13 +363,9 @@ def _selection_tab(self): # 4. Button to clear selection. clear_selection = ipw.Button(description="Clear selection") - # clear_selection.on_click(lambda _: self.set_trait('selection', list())) # lambda cannot contain assignments clear_selection.on_click( - lambda _: ( - self.set_trait("displayed_selection", []), - self.set_trait("selection", []), - ) - ) + lambda _: self.set_trait("displayed_selection", []) + ) # lambda cannot contain assignments # 5. Button to apply selection apply_displayed_selection = ipw.Button(description="Apply selection") @@ -274,11 +411,20 @@ def change_supercell(_=None): for elem in _supercell: elem.observe(change_supercell, names="value") supercell_selector = ipw.HBox( - [ipw.HTML(description="Super cell:")] + _supercell + [ + ipw.HTML( + description="Super cell:", style={"description_width": "initial"} + ) + ] + + _supercell ) # 2. Choose background color. - background_color = ipw.ColorPicker(description="Background") + background_color = ipw.ColorPicker( + description="Background", + style={"description_width": "initial"}, + layout={"width": "200px"}, + ) tl.link((background_color, "value"), (self._viewer, "background")) background_color.value = "white" @@ -300,10 +446,147 @@ def change_camera(change): center_button = ipw.Button(description="Center molecule") center_button.on_click(lambda c: self._viewer.center()) + # 5. representations buttons + self.representations_header = ipw.HBox( + [ + ipw.HTML( + """

Show

""", + layout={"width": "40px"}, + ), + ipw.HTML( + """

Atoms

""", + layout={"width": "80px"}, + ), + ipw.HTML( + """

Type

""", + layout={"width": "100px"}, + ), + ipw.HTML( + """

Size

""", + layout={"width": "40px"}, + ), + ipw.HTML( + """

Color

""", + layout={"width": "80px"}, + ), + ipw.HTML( + """

Delete

""", + layout={"width": "50px"}, + ), + ] + ) + self.atoms_not_represented = ipw.HTML() + add_new_representation_button = ipw.Button( + description="Add representation", button_style="info" + ) + add_new_representation_button.on_click(self._add_representation) + + apply_representations = ipw.Button(description="Apply representations") + apply_representations.on_click(self._apply_representations) + self.representation_output = ipw.VBox() + + # The default representation is always present and cannot be deleted. + self._all_representations = [ + NglViewerRepresentation( + uuid=self.DEFAULT_REPRESENTATION, + deletable=False, + atom_show_threshold=0, + ) + ] + + representation_accordion = ipw.Accordion( + children=[ + ipw.VBox( + [ + self.representations_header, + self.representation_output, + self.atoms_not_represented, + ipw.HBox( + [apply_representations, add_new_representation_button] + ), + ] + ) + ], + ) + representation_accordion.set_title(0, "Representations") + representation_accordion.selected_index = None + return ipw.VBox( - [supercell_selector, background_color, camera_type, center_button] + [ + supercell_selector, + background_color, + camera_type, + center_button, + representation_accordion, + ] + ) + + def _add_representation(self, _=None, uuid=None, indices=None): + """Add a representation to the list of representations.""" + self._all_representations = self._all_representations + [ + NglViewerRepresentation( + uuid=uuid or f"{self.REPRESENTATION_PREFIX}{shortuuid.uuid()}", + indices=indices, + ) + ] + self._apply_representations() + + def delete_representation(self, representation: NglViewerRepresentation): + try: + index = self._all_representations.index(representation) + except ValueError: + self.representation_add_message.message = f"""Error: Rep. {representation} not found.""" + return + + self._all_representations = ( + self._all_representations[:index] + self._all_representations[index + 1 :] ) + if representation.uuid in self.structure.arrays: + del self.structure.arrays[representation.uuid] + del representation + self._apply_representations() + + @tl.observe("_all_representations") + def _observe_all_representations(self, change): + """Update the list of representations.""" + self.representation_output.children = change["new"] + if change["new"]: + self._all_representations[-1].viewer_class = self + + def _apply_representations(self, change=None): + """Apply the representations to the displayed structure.""" + rep_uuids = [] + + # Representation can only be applied if a structure is present. + if self.structure is None: + return + + # Add existing representations to the structure. + for representation in self._all_representations: + representation.add_myself_to_atoms_object(self.structure) + rep_uuids.append(representation.uuid) + + # Remove missing representations from the structure. + for array in self.structure.arrays: + if array.startswith(self.REPRESENTATION_PREFIX) and array not in rep_uuids: + del self.structure.arrays[array] + self._observe_structure({"new": self.structure}) + self._check_missing_atoms_in_representations() + + def _check_missing_atoms_in_representations(self): + missing_atoms = np.zeros(self.natoms) + for rep in self._all_representations: + missing_atoms += rep.atoms_in_representaion(self.structure) + missing_atoms = np.where(missing_atoms == 0)[0] + if len(missing_atoms) > 0: + self.atoms_not_represented.value = ( + "Atoms excluded from representations: " + + list_to_string_range(list(missing_atoms), shift=1) + ) + else: + self.atoms_not_represented.value = "" + @tl.observe("cell") def _observe_cell(self, _=None): # Updtate the Cell and Periodicity. @@ -491,7 +774,7 @@ def _render_structure(self, change=None): zfactor = np.linalg.norm(omat[0, 0:3]) omat[0:3, 0:3] = omat[0:3, 0:3] / zfactor - bb = deepcopy(self.displayed_structure) + bb = copy.deepcopy(self.displayed_structure) bb.pbc = (False, False, False) for i in bb: @@ -620,40 +903,69 @@ def _render_structure(self, change=None): def _on_atom_click(self, _=None): """Update selection when clicked on atom.""" - if "atom1" not in self._viewer.picked.keys(): - return # did not click on atom - index = self._viewer.picked["atom1"]["index"] - displayed_selection = self.displayed_selection.copy() - - if displayed_selection: - if index not in displayed_selection: - displayed_selection.append(index) + if hasattr(self._viewer, "component_0"): + # Did not click on atom: + if "atom1" not in self._viewer.picked.keys(): + return + + index = self._viewer.picked["atom1"]["index"] + + displayed_selection = self.displayed_selection.copy() + if displayed_selection: + if index not in displayed_selection: + displayed_selection.append(index) + else: + displayed_selection.remove(index) else: - displayed_selection.remove(index) - else: - displayed_selection = [index] - self.displayed_selection = displayed_selection + displayed_selection = [index] + self.displayed_selection = displayed_selection def highlight_atoms( self, - vis_list, - color=DEFAULT_SELECTION_COLOR, - size=DEFAULT_SELECTION_RADIUS, - opacity=DEFAULT_SELECTION_OPACITY, + list_of_atoms, ): """Highlighting atoms according to the provided list.""" if not hasattr(self._viewer, "component_0"): return - self._viewer._remove_representations_by_name( - repr_name="selected_atoms" - ) # pylint:disable=protected-access - self._viewer.add_ball_and_stick( # pylint:disable=no-member - name="selected_atoms", - selection=[] if vis_list is None else vis_list, - color=color, - aspectRatio=size, - opacity=opacity, - ) + + # Create the dictionaries for highlight_representations. + for i, representation in enumerate(self._all_representations): + # First remove the previous highlight_representation. + self._viewer._remove_representations_by_name( + repr_name=f"highlight_representation_{i}", component=0 + ) + + # Then add the new one if needed. + indices = np.intersect1d( + list_of_atoms, + np.where( + representation.atoms_in_representaion(self.displayed_structure) + )[0], + ) + if len(indices) > 0: + params = representation.nglview_parameters(indices) + params["params"]["name"] = f"highlight_representation_{i}" + params["params"]["opacity"] = 0.8 + params["params"]["color"] = "darkgreen" + params["params"]["component_index"] = 0 + if "radiusScale" in params["params"]: + params["params"]["radiusScale"] *= 1.2 + else: + params["params"]["aspectRatio"] *= 1.2 + + # Use directly the remote call for more flexibility. + self._viewer._remote_call( + "addRepresentation", + target="compList", + args=[params["type"]], + kwargs=params["params"], + ) + + def remove_viewer_components(self, c=None): + if hasattr(self._viewer, "component_0"): + self._viewer.component_0.clear_representations() + cid = self._viewer.component_0.id + self._viewer.remove_component(cid) @tl.default("supercell") def _default_supercell(self): @@ -665,12 +977,12 @@ def _observe_input_selection(self, value): return # Exclude everything that is beyond the total number of atoms. - selection_list = [x for x in value["new"] if x < self.natom] + selection_list = [x for x in value["new"] if x < self.natoms] # In the case of a super cell, we need to multiply the selection as well multiplier = sum(self.supercell) - 2 selection_list = [ - x + self.natom * i for x in selection_list for i in range(multiplier) + x + self.natoms * i for x in selection_list for i in range(multiplier) ] self.displayed_selection = selection_list @@ -678,7 +990,7 @@ def _observe_input_selection(self, value): @tl.observe("displayed_selection") def _observe_displayed_selection(self, _=None): seen = set() - seq = [x % self.natom for x in self.displayed_selection] + seq = [x % self.natoms for x in self.displayed_selection] self.selection = [x for x in seq if not (x in seen or seen.add(x))] self.highlight_atoms(self.displayed_selection) @@ -749,6 +1061,11 @@ def _prepare_payload(self, file_format=None): def thumbnail(self): return self._prepare_payload(file_format="png") + @property + def natoms(self): + """Number of atoms in the structure.""" + return 0 if self.structure is None else len(self.structure) + @register_viewer_widget("data.core.cif.CifData.") @register_viewer_widget("data.core.structure.StructureData.") @@ -766,7 +1083,12 @@ class StructureDataViewer(_StructureDataBaseViewer): """ structure = tl.Union( - [tl.Instance(ase.Atoms), tl.Instance(orm.Node)], allow_none=True + [ + tl.Instance(ase.Atoms), + tl.Instance(orm.StructureData), + tl.Instance(orm.CifData), + ], + allow_none=True, ) displayed_structure = tl.Instance(ase.Atoms, allow_none=True, read_only=True) pk = tl.Int(allow_none=True) @@ -774,60 +1096,98 @@ class StructureDataViewer(_StructureDataBaseViewer): def __init__(self, structure=None, **kwargs): super().__init__(**kwargs) self.structure = structure - self.natom = len(self.structure) if self.structure is not None else 0 @tl.observe("supercell") - def repeat(self, _=None): + def _observe_supercell(self, _=None): if self.structure is not None: + self.set_trait( + "displayed_structure", None + ) # To make sure the structure is always updated. self.set_trait("displayed_structure", self.structure.repeat(self.supercell)) @tl.validate("structure") - def _valid_structure(self, change): # pylint: disable=no-self-use + def _valid_structure(self, change): """Update structure.""" structure = change["value"] - - if structure is None: - return None # if no structure provided, the rest of the code can be skipped - if isinstance(structure, ase.Atoms): self.pk = None - return structure - if isinstance(structure, orm.Node): + elif isinstance(structure, (orm.StructureData, orm.CifData)): self.pk = structure.pk - return structure.get_ase() - raise TypeError( - f"Unsupported type {type(structure)}, structure must be one of the following types: " - "ASE Atoms object, AiiDA CifData or StructureData." - ) + structure = structure.get_ase() + + # Add default representation array if it is not present. + # This will make sure that the new structure is displayed at the beginning. + if self.DEFAULT_REPRESENTATION not in structure.arrays: + structure.set_array( + self.DEFAULT_REPRESENTATION, + np.zeros(len(structure), dtype=int), + ) + return structure # This also includes the case when the structure is None. @tl.observe("structure") - def _observe_structure(self, change): + def _observe_structure(self, change=None): """Update displayed_structure trait after the structure trait has been modified.""" - self.natom = len(self.structure) if self.structure is not None else 0 - # Remove the current structure(s) from the viewer. - if change["new"] is not None: - self.set_trait("displayed_structure", change["new"].repeat(self.supercell)) - self.set_trait("cell", change["new"].cell) - else: + structure = change["new"] + + self._viewer.clear_representations(component=0) + + if not structure: self.set_trait("displayed_structure", None) self.set_trait("cell", None) + return + + # Make sure that the representation arrays from structure are present in the viewer. + structure_uuids = [ + uuid + for uuid in structure.arrays + if uuid.startswith(self.REPRESENTATION_PREFIX) + ] + rep_uuids = [rep.uuid for rep in self._all_representations] + + for uuid in structure_uuids: + try: + index = rep_uuids.index(uuid) + self._all_representations[index].sync_myself_to_array_from_atoms_object( + structure + ) + except ValueError: + self._add_representation( + uuid=uuid, + indices=np.where(structure.arrays[self.uuid] >= 1)[0], + ) + # Empty atoms selection for the representations that are not present in the structure. + # Typically this happens when a new structure is imported. + for i, uuid in enumerate(rep_uuids): + if uuid not in structure_uuids: + self._all_representations[i].selection.value = "" + + self._observe_supercell() # To trigger an update of the displayed structure + self.set_trait("cell", structure.cell) @tl.observe("displayed_structure") - def _update_structure_viewer(self, change): + def _observe_displayed_structure(self, change): """Update the view if displayed_structure trait was modified.""" with self.hold_trait_notifications(): - for ( - comp_id - ) in self._viewer._ngl_component_ids: # pylint: disable=protected-access - self._viewer.remove_component(comp_id) - self.displayed_selection = [] - if change["new"] is not None: - self._viewer.add_component(nglview.ASEStructure(change["new"])) - self._viewer.clear() - self._viewer.add_ball_and_stick( - aspectRatio=4 - ) # pylint: disable=no-member - self._viewer.add_unitcell() # pylint: disable=no-member + self.remove_viewer_components() + if change["new"]: + self._viewer.add_component( + nglview.ASEStructure(self.displayed_structure), + default_representation=False, + name="Structure", + ) + nglview_params = [ + rep.nglview_parameters( + np.where(rep.atoms_in_representaion(self.displayed_structure))[ + 0 + ] + ) + for rep in self._all_representations + if rep.show.value + ] + self._viewer.set_representations(nglview_params, component=0) + self._viewer.add_unitcell() + self._viewer.center() + self.displayed_selection = [] def d_from(self, operand): point = np.array([float(i) for i in operand[1:-1].split(",")]) @@ -1011,7 +1371,7 @@ def add_info(indx, atom): return f"

Id: {indx + 1}; Symbol: {atom.symbol}; Coordinates: ({print_pos(atom.position)})

" # Unit and displayed cell atoms' selection. - info_selected_atoms = ( + info = ( f"

Selected atoms: {list_to_string_range(self.displayed_selection, shift=1)}

" + f"

Selected unit cell atoms: {list_to_string_range(self.selection, shift=1)}

" ) @@ -1025,14 +1385,13 @@ def add_info(indx, atom): # Report coordinates. if len(self.displayed_selection) == 1: - return info_selected_atoms + add_info( + info += add_info( self.displayed_selection[0], self.displayed_structure[self.displayed_selection[0]], ) # Report coordinates, distance and center. - if len(self.displayed_selection) == 2: - info = "" + elif len(self.displayed_selection) == 2: info += add_info( self.displayed_selection[0], self.displayed_structure[self.displayed_selection[0]], @@ -1045,14 +1404,13 @@ def add_info(indx, atom): distv = self.displayed_structure.get_distance( *self.displayed_selection, vector=True ) - info += f"

Distance: {dist:.3f} ({print_pos(distv)})

Geometric center: ({geom_center})

" - return info_selected_atoms + info - - info_natoms_geo_center = f"

{len(self.displayed_selection)} atoms selected

Geometric center: ({geom_center})

" + info += f"

Distance: {dist:.2f} ({print_pos(distv)})

" # Report angle geometric center and normal. - if len(self.displayed_selection) == 3: - angle = self.displayed_structure.get_angle(*self.displayed_selection) + elif len(self.displayed_selection) == 3: + angle = self.displayed_structure.get_angle(*self.displayed_selection).round( + 2 + ) normal = np.cross( *self.displayed_structure.get_distances( self.displayed_selection[1], @@ -1062,13 +1420,10 @@ def add_info(indx, atom): ) ) normal = normal / np.linalg.norm(normal) - return ( - info_selected_atoms - + f"

{info_natoms_geo_center}

Angle: {angle: .3f}

Normal: ({print_pos(normal)})

" - ) + info += f"

Angle: {angle}, Normal: ({print_pos(normal)})

" # Report dihedral angle and geometric center. - if len(self.displayed_selection) == 4: + elif len(self.displayed_selection) == 4: try: dihedral = self.displayed_structure.get_dihedral( *self.displayed_selection @@ -1076,12 +1431,13 @@ def add_info(indx, atom): dihedral_str = f"{dihedral:.3f}" except ZeroDivisionError: dihedral_str = "nan" - return ( - info_selected_atoms - + f"

{info_natoms_geo_center}

Dihedral angle: {dihedral_str}

" - ) + info += f"

Dihedral angle: {dihedral_str}

" - return info_selected_atoms + info_natoms_geo_center + return ( + info + + f"

Geometric center: ({geom_center})

" + + f"

{len(self.displayed_selection)} atoms selected

" + ) @tl.observe("displayed_selection") def _observe_displayed_selection_2(self, _=None): diff --git a/aiidalab_widgets_base/wizard.py b/aiidalab_widgets_base/wizard.py index 00691d0b4..9160b32ba 100644 --- a/aiidalab_widgets_base/wizard.py +++ b/aiidalab_widgets_base/wizard.py @@ -107,7 +107,7 @@ def __init__(self, steps, **kwargs): for widget in widgets: if not widget.has_trait("state"): raise TypeError( - f"The provided '{widget}' as wizard app step has no `state` trait. " + f"The provided {widget!r} as wizard app step has no `state` trait. " "It is expected that step classes are derived from the WizardAppWidgetStep class." ) widget.observe(self._update_step_state, names=["state"]) diff --git a/docs/source/conf.py b/docs/source/conf.py index 66cf0928d..46706aaad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -94,6 +94,11 @@ # of the sidebar. html_logo = "_static/aiidalab_logo.png" + +html_theme_options = { + "navigation_with_keys": True, +} + # -- Modifications for Readthedocs ---------------------------------------- diff --git a/tests/test_structures.py b/tests/test_structures.py index 4dba080bb..823d7615c 100644 --- a/tests/test_structures.py +++ b/tests/test_structures.py @@ -17,8 +17,8 @@ def test_structure_manager_widget(structure_data_object): editors=[ awb.BasicStructureEditor(title="Basic Editor"), ], + input_structure=structure_data_object, ) - structure_manager_widget.input_structure = structure_data_object assert structure_manager_widget.structure is not None assert isinstance(structure_manager_widget.structure, ase.Atoms) @@ -27,10 +27,14 @@ def test_structure_manager_widget(structure_data_object): assert structure_manager_widget.viewer.periodicity.value == "Periodicity: xyz" # Store structure and check that it is stored. - structure_manager_widget.store_structure() + structure_manager_widget.btn_store.click() assert structure_manager_widget.structure_node.is_stored assert structure_manager_widget.structure_node.pk is not None + # Try to store the stored structure. + structure_manager_widget.btn_store.click() + assert "Already stored" in structure_manager_widget.output.value + # Simulate the structure modification. new_structure = structure_manager_widget.structure.copy() new_structure[0].position += [0, 0, 1] @@ -41,20 +45,41 @@ def test_structure_manager_widget(structure_data_object): structure_manager_widget.structure[0].position == new_structure[0].position ) + # Store the modified structure. + structure_manager_widget.btn_store.click() + assert structure_manager_widget.structure_node.is_stored + # Undo the structure modification. structure_manager_widget.undo() assert np.any( structure_manager_widget.structure[0].position != new_structure[0].position ) + structure_manager_widget.undo() # Undo the structure creation. + assert structure_manager_widget.structure is None + - # test the widget can be instantiated with empty inputs +@pytest.mark.usefixtures("aiida_profile_clean") +def test_structure_manager_widget_multiple_importers_editors(structure_data_object): + # Test the widget with multiple importers, editors. Specify the viewer and the node class + base_editor = awb.BasicStructureEditor(title="Basic Editor") structure_manager_widget = awb.StructureManagerWidget( importers=[ awb.StructureUploadWidget(title="From computer"), + awb.StructureBrowserWidget(title="AiiDA database"), ], + editors=[ + base_editor, + awb.BasicCellEditor(title="Cell Editor"), + ], + viewer=awb.viewers.StructureDataViewer(), + node_class="StructureData", ) assert structure_manager_widget.structure is None + structure_manager_widget.input_structure = structure_data_object + + # Set action vector perpendicular to screen. + base_editor.def_perpendicular_to_screen() @pytest.mark.usefixtures("aiida_profile_clean") @@ -222,12 +247,16 @@ def test_basic_structure_editor(structure_data_object): # Set the structure. widget.structure = structure_data_object.get_ase() - # Set first action point vector to the first atom. + # Set first vector point vector to the first atom. widget.selection = [0] widget.def_axis_p1() assert widget.axis_p1.value == "0.0 0.0 0.0" - # Set second action point vector to the second atom. + # Set action point to the first atom. + widget.def_point() + assert widget.point.value == "0.0 0.0 0.0" + + # Set second vector point vector to the second atom. widget.selection = [1] widget.def_axis_p2() assert widget.axis_p2.value == "1.92 1.11 0.79" @@ -279,3 +308,10 @@ def test_basic_structure_editor(structure_data_object): widget.element.value = "O" widget.mod_element() assert widget.structure.get_chemical_formula() == "COSi" + + # Add a ligand. + widget.ligand.label = "Methyl -CH3" + widget.selection = [2] + widget.add() + assert len(widget.structure) == 7 + assert widget.structure.get_chemical_formula() == "C2H3OSi" diff --git a/tests/test_viewers.py b/tests/test_viewers.py index 10db23713..ff4cd3690 100644 --- a/tests/test_viewers.py +++ b/tests/test_viewers.py @@ -1,4 +1,6 @@ +import ase import pytest +import traitlets as tl from aiida import orm from aiidalab_widgets_base import viewers @@ -7,9 +9,6 @@ @pytest.mark.usefixtures("aiida_profile_clean") def test_pbc_structure_data_viewer(structure_data_object): """Test the periodicity of the structure viewer widget.""" - - import ase - # Prepare a structure with periodicity xy ase_input = ase.Atoms( symbols="Li2", @@ -57,21 +56,49 @@ def test_several_data_viewers( @pytest.mark.usefixtures("aiida_profile_clean") -def test_structure_data_viewer(structure_data_object): +def test_structure_data_viewer_storage(structure_data_object): v = viewers.viewer(structure_data_object) assert isinstance(v, viewers.StructureDataViewer) - # Selection of atoms. + # Check the `_prepare_payload` function used for downloading. + format_cases = [ + ( + "Extended xyz", + """MgpMYXR0aWNlPSIzLjg0NzM3IDAuMCAwLjAgMS45MjM2ODUgMy4zMzE5MiAwLjAgMS45MjM2ODUgMS4xMTA2NCAzLjE0MTM2NCIgUHJvcGVydGllcz1zcGVjaWVzOlM6MTpwb3M6UjozOm1hc3NlczpSOjE6X2FpaWRhbGFiX3ZpZXdlcl9yZXByZXNlbnRhdGlvbl9kZWZhdWx0Okk6MSBwYmM9IlQgVCBUIgpTaSAgICAgICAwLjAwMDAwMDAwICAgICAgIDAuMDAwMDAwMDAgICAgICAgMC4wMDAwMDAwMCAgICAgIDI4LjA4NTUwMDAwICAgICAgICAwClNpICAgICAgIDEuOTIzNjg1MDAgICAgICAgMS4xMTA2NDAwMCAgICAgICAwLjc4NTM0MTAwICAgICAgMjguMDg1NTAwMDAgICAgICAgIDAK""", + ), + ( + "xsf", + """Q1JZU1RBTApQUklNVkVDCiAzLjg0NzM3MDAwMDAwMDAwIDAuMDAwMDAwMDAwMDAwMDAgMC4wMDAwMDAwMDAwMDAwMAogMS45MjM2ODUwMDAwMDAwMCAzLjMzMTkyMDAwMDAwMDAwIDAuMDAwMDAwMDAwMDAwMDAKIDEuOTIzNjg1MDAwMDAwMDAgMS4xMTA2NDAwMDAwMDAwMCAzLjE0MTM2NDAwMDAwMDAwClBSSU1DT09SRAogMiAxCiAxNCAgICAgMC4wMDAwMDAwMDAwMDAwMCAgICAgMC4wMDAwMDAwMDAwMDAwMCAgICAgMC4wMDAwMDAwMDAwMDAwMAogMTQgICAgIDEuOTIzNjg1MDAwMDAwMDAgICAgIDEuMTEwNjQwMDAwMDAwMDAgICAgIDAuNzg1MzQxMDAwMDAwMDAK""", + ), + ( + "cif", + """ZGF0YV9pbWFnZTAKX2NoZW1pY2FsX2Zvcm11bGFfc3RydWN0dXJhbCAgICAgICBTaTIKX2NoZW1pY2FsX2Zvcm11bGFfc3VtICAgICAgICAgICAgICAiU2kyIgpfY2VsbF9sZW5ndGhfYSAgICAgICAzLjg0NzM3Cl9jZWxsX2xlbmd0aF9iICAgICAgIDMuODQ3MzcKX2NlbGxfbGVuZ3RoX2MgICAgICAgMy44NDczNwpfY2VsbF9hbmdsZV9hbHBoYSAgICA2MApfY2VsbF9hbmdsZV9iZXRhICAgICA2MApfY2VsbF9hbmdsZV9nYW1tYSAgICA2MAoKX3NwYWNlX2dyb3VwX25hbWVfSC1NX2FsdCAgICAiUCAxIgpfc3BhY2VfZ3JvdXBfSVRfbnVtYmVyICAgICAgIDEKCmxvb3BfCiAgX3NwYWNlX2dyb3VwX3N5bW9wX29wZXJhdGlvbl94eXoKICAneCwgeSwgeicKCmxvb3BfCiAgX2F0b21fc2l0ZV90eXBlX3N5bWJvbAogIF9hdG9tX3NpdGVfbGFiZWwKICBfYXRvbV9zaXRlX3N5bW1ldHJ5X211bHRpcGxpY2l0eQogIF9hdG9tX3NpdGVfZnJhY3RfeAogIF9hdG9tX3NpdGVfZnJhY3RfeQogIF9hdG9tX3NpdGVfZnJhY3RfegogIF9hdG9tX3NpdGVfb2NjdXBhbmN5CiAgU2kgIFNpMSAgICAgICAxLjAgIDAuMDAwMDAgIDAuMDAwMDAgIDAuMDAwMDAgIDEuMDAwMAogIFNpICBTaTIgICAgICAgMS4wICAwLjI1MDAwICAwLjI1MDAwICAwLjI1MDAwICAxLjAwMDAK""", + ), + ] + + for fmt, out in format_cases: + v.file_format.label = fmt + assert v._prepare_payload() == out + + +@pytest.mark.usefixtures("aiida_profile_clean") +def test_structure_data_viewer_selection(structure_data_object): + v = viewers.viewer(structure_data_object) # Direct selection. v._selected_atoms.value = "1..2" v.apply_displayed_selection() assert v.selection == [0, 1] + assert v.displayed_selection == [0, 1] + assert "Distance" in v.selection_info.value + assert "2 atoms selected" in v.selection_info.value # The x coordinate lower than 0.5. v._selected_atoms.value = "x<0.5" v.apply_displayed_selection() assert v.selection == [0] + assert v.displayed_selection == [0] + assert "1 atoms selected" in v.selection_info.value # The id of the second atom v._selected_atoms.value = "id > 1" @@ -79,26 +106,135 @@ def test_structure_data_viewer(structure_data_object): assert v.selection == [1] # or of the two selections. - v._selected_atoms.value = "x>0.5 or x<0.5" + v._selected_atoms.value = "x>=0.5 or x<0.5" v.apply_displayed_selection() assert v.selection == [0, 1] - # Check the `_prepare_payload` function used for downloading. - format_cases = [ - ( - "Extended xyz", - """MgpMYXR0aWNlPSIzLjg0NzM3IDAuMCAwLjAgMS45MjM2ODUgMy4zMzE5MiAwLjAgMS45MjM2ODUgMS4xMTA2NCAzLjE0MTM2NCIgUHJvcGVydGllcz1zcGVjaWVzOlM6MTpwb3M6UjozOm1hc3NlczpSOjEgcGJjPSJUIFQgVCIKU2kgICAgICAgMC4wMDAwMDAwMCAgICAgICAwLjAwMDAwMDAwICAgICAgIDAuMDAwMDAwMDAgICAgICAyOC4wODU1MDAwMApTaSAgICAgICAxLjkyMzY4NTAwICAgICAgIDEuMTEwNjQwMDAgICAgICAgMC43ODUzNDEwMCAgICAgIDI4LjA4NTUwMDAwCg==""", - ), - ( - "xsf", - """Q1JZU1RBTApQUklNVkVDCiAzLjg0NzM3MDAwMDAwMDAwIDAuMDAwMDAwMDAwMDAwMDAgMC4wMDAwMDAwMDAwMDAwMAogMS45MjM2ODUwMDAwMDAwMCAzLjMzMTkyMDAwMDAwMDAwIDAuMDAwMDAwMDAwMDAwMDAKIDEuOTIzNjg1MDAwMDAwMDAgMS4xMTA2NDAwMDAwMDAwMCAzLjE0MTM2NDAwMDAwMDAwClBSSU1DT09SRAogMiAxCiAxNCAgICAgMC4wMDAwMDAwMDAwMDAwMCAgICAgMC4wMDAwMDAwMDAwMDAwMCAgICAgMC4wMDAwMDAwMDAwMDAwMAogMTQgICAgIDEuOTIzNjg1MDAwMDAwMDAgICAgIDEuMTEwNjQwMDAwMDAwMDAgICAgIDAuNzg1MzQxMDAwMDAwMDAK""", - ), - ( - "cif", - """ZGF0YV9pbWFnZTAKX2NoZW1pY2FsX2Zvcm11bGFfc3RydWN0dXJhbCAgICAgICBTaTIKX2NoZW1pY2FsX2Zvcm11bGFfc3VtICAgICAgICAgICAgICAiU2kyIgpfY2VsbF9sZW5ndGhfYSAgICAgICAzLjg0NzM3Cl9jZWxsX2xlbmd0aF9iICAgICAgIDMuODQ3MzcKX2NlbGxfbGVuZ3RoX2MgICAgICAgMy44NDczNwpfY2VsbF9hbmdsZV9hbHBoYSAgICA2MApfY2VsbF9hbmdsZV9iZXRhICAgICA2MApfY2VsbF9hbmdsZV9nYW1tYSAgICA2MAoKX3NwYWNlX2dyb3VwX25hbWVfSC1NX2FsdCAgICAiUCAxIgpfc3BhY2VfZ3JvdXBfSVRfbnVtYmVyICAgICAgIDEKCmxvb3BfCiAgX3NwYWNlX2dyb3VwX3N5bW9wX29wZXJhdGlvbl94eXoKICAneCwgeSwgeicKCmxvb3BfCiAgX2F0b21fc2l0ZV90eXBlX3N5bWJvbAogIF9hdG9tX3NpdGVfbGFiZWwKICBfYXRvbV9zaXRlX3N5bW1ldHJ5X211bHRpcGxpY2l0eQogIF9hdG9tX3NpdGVfZnJhY3RfeAogIF9hdG9tX3NpdGVfZnJhY3RfeQogIF9hdG9tX3NpdGVfZnJhY3RfegogIF9hdG9tX3NpdGVfb2NjdXBhbmN5CiAgU2kgIFNpMSAgICAgICAxLjAgIDAuMDAwMDAgIDAuMDAwMDAgIDAuMDAwMDAgIDEuMDAwMAogIFNpICBTaTIgICAgICAgMS4wICAwLjI1MDAwICAwLjI1MDAwICAwLjI1MDAwICAxLjAwMDAK""", - ), - ] + # Display 2*2*2 supercell + v.supercell = [2, 2, 2] + assert len(v.structure) == 2 + assert len(v.displayed_structure) == 16 - for fmt, out in format_cases: - v.file_format.label = fmt - assert v._prepare_payload() == out + # Test intersection of the selection with the supercell. + v._selected_atoms.value = "z>0 and z<2.5" + v.apply_displayed_selection() + assert v.selection == [1] + assert v.displayed_selection == [1, 5, 9, 13] + + v._selected_atoms.value = "x<=2.0 and z<3" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [4, 0, 1] + assert "Angle" in v.selection_info.value + assert "3 atoms selected" in v.selection_info.value + + # Convert to boron nitride. + new_structure = v.structure.copy() + new_structure.symbols = ["B", "N"] + v.structure = None + v.structure = new_structure + + # Use "name" and "not" operators. + v._selected_atoms.value = "z<2 and name B" + v.apply_displayed_selection() + assert v.selection == [0] + assert v.displayed_selection == [0, 4, 8, 12] + + v._selected_atoms.value = "z<2 and name not B" + v.apply_displayed_selection() + assert v.selection == [1] + assert v.displayed_selection == [1, 5, 9, 13] + + v._selected_atoms.value = "z<2 and name not [B, O]" + v.apply_displayed_selection() + assert v.selection == [1] + assert v.displayed_selection == [1, 5, 9, 13] + + # Use "id" operator. + v._selected_atoms.value = "id == 1 or id == 8" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [0, 7] + + # Use the d_from operator. + v._selected_atoms.value = "d_from[0,0,0] < 4" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [4, 8, 0, 1, 2] + + # Use the != operator. + v._selected_atoms.value = "id != 5" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + + # Use ^ and - operators. + v._selected_atoms.value = "(x-4)^2 + (y-2)^2 < 4" + v.apply_displayed_selection() + assert v.selection == [1, 0] + assert v.displayed_selection == [3, 9, 10] + + # Division and multiplication. + v._selected_atoms.value = "x/2 < 1" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [4, 0, 1, 2] + + v._selected_atoms.value = "x*1.5 < y + z" + v.apply_displayed_selection() + assert v.selection == [0, 1] + assert v.displayed_selection == [2, 3, 4, 6, 7] + + # Test wrong syntax. + assert v.wrong_syntax.layout.visibility == "hidden" + v._selected_atoms.value = "x--x" + v.apply_displayed_selection() + assert v.wrong_syntax.layout.visibility == "visible" + + +@pytest.mark.usefixtures("aiida_profile_clean") +def test_structure_data_viewer_representation(structure_data_object): + v = viewers.viewer(structure_data_object) + + # By default, there should be one "default" representation. + assert len(v._all_representations) == 1 + assert v._all_representations[0].uuid == "_aiidalab_viewer_representation_default" + assert v._all_representations[0].selection.value == "1..2" + + # Display only one atom. + v._all_representations[0].selection.value = "1" + v._apply_representations() + assert "2" in v.atoms_not_represented.value + + # Add a new representation. + v._add_representation() + assert "2" in v.atoms_not_represented.value + v._all_representations[1].selection.value = "2" + v._all_representations[0].type.value = "ball+stick" + v._all_representations[1].type.value = "spacefill" + v._apply_representations() + assert v.atoms_not_represented.value == "" + + # Add an atom to the structure. + new_structure = v.structure.copy() + new_structure.append(ase.Atom("C", (0.5, 0.5, 0.5))) + v.structure = None + v.structure = new_structure + + # The new atom should appear in the default representation. + assert v._all_representations[0].selection.value == "1 3" + assert "3" not in v.atoms_not_represented.value + + # Delete the second representation. + assert v._all_representations[0].delete_button.layout.visibility == "hidden" + assert v._all_representations[1].delete_button.layout.visibility == "visible" + v._all_representations[1].delete_button.click() + assert len(v._all_representations) == 1 + assert "2" in v.atoms_not_represented.value + + # Try to provide different object type than the viewer accepts. + with pytest.raises(tl.TraitError): + v.structure = 2 + + with pytest.raises(tl.TraitError): + v.structure = orm.Int(1)