Skip to content

FragmentRetro Interface

This is the GUI Implementation for FragmentRetro.

Example Use

In a Jupyter notebook:

from fragmentretro.app.interface import display_gui

app_logger.setLevel(logging.INFO) 

# To display the GUI without any initial SMILES input
app = display_gui()

# Alternatively, to display the GUI with a predefined SMILES string
app = display_gui(smiles="CCNCC")

Source Code

fragmentretro.app.interface

display_gui(smiles=None)

Instantiates state, controller, connects handlers, and displays the GUI.

Source code in src/fragmentretro/app/interface.py
def display_gui(smiles: str | None = None) -> None:
    """Instantiates state, controller, connects handlers, and displays the GUI."""

    # 1. Instantiate State
    app_state = AppState()

    # 2. Instantiate Controller (pass state)
    # The controller __init__ already references the imported widget instances
    controller = GuiController(app_state)

    # 3. Register Event Handlers
    controller.register_event_handlers()

    # 4. Initial UI Setup / Reset
    if smiles:
        target_smiles_input.value = smiles

    controller.reset_ui_outputs()  # Reset UI elements via controller
    app_state.reset_run_state()  # Reset backend state as well

    # 5. Display Layout
    display(gui_layout)  # type: ignore

fragmentretro.app.gui.controller

GuiController

Manages GUI event handling and interactions.

Source code in src/fragmentretro/app/gui/controller.py
class GuiController:
    """Manages GUI event handling and interactions."""

    def __init__(self, state: AppState):
        self.state = state
        self.target_smiles_input = target_smiles_input
        self.fragmenter_choice = fragmenter_choice
        self.file_path_input = file_path_input
        self.parallelize_checkbox = parallelize_checkbox
        self.num_cores_input = num_cores_input
        self.core_factor_input = core_factor_input
        self.run_button = run_button
        self.output_area = output_area
        self.filter_checkbox = filter_checkbox
        self.fragment_count_input = fragment_count_input
        self.display_button = display_button
        self.solution_output_area = solution_output_area
        self.solution_dropdown = solution_dropdown
        self.image_display_area = image_display_area
        self.fragment_comb_dropdown = fragment_comb_dropdown
        self.smiles_display_area = smiles_display_area
        self.prev_smiles_button = prev_smiles_button
        self.smiles_pagination_label = smiles_pagination_label
        self.next_smiles_button = next_smiles_button
        self.sort_smiles_button = sort_smiles_button

    def register_event_handlers(self) -> None:
        """Connects widget events to controller methods."""
        self.parallelize_checkbox.observe(self.handle_parallelize_change, names="value")
        self.filter_checkbox.observe(self.handle_filter_change, names="value")
        self.run_button.on_click(self.run_retrosynthesis_on_click)
        self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")
        self.prev_smiles_button.on_click(self.on_prev_smiles_click)
        self.next_smiles_button.on_click(self.on_next_smiles_click)
        self.sort_smiles_button.on_click(self.on_sort_smiles_click)
        self.display_button.on_click(self.display_solutions_on_click)
        self.solution_dropdown.observe(self.on_solution_select, names="value")

    def handle_parallelize_change(self, change: dict[str, bool]) -> None:
        """Callback to enable/disable core inputs based on parallelize checkbox."""
        is_parallel = change.get("new", False)
        self.num_cores_input.disabled = not is_parallel
        self.core_factor_input.disabled = not is_parallel

    def handle_filter_change(self, change: dict[str, bool]) -> None:
        """Callback to enable/disable fragment count input based on filter checkbox."""
        is_filtered = change.get("new", False)
        self.fragment_count_input.disabled = not is_filtered
        if not is_filtered:
            self.fragment_count_input.value = None  # type: ignore

    def reset_ui_outputs(self) -> None:
        """Resets output areas, dropdowns, and buttons to their initial states."""
        self.solution_dropdown.options = []
        self.solution_dropdown.value = None
        self.solution_dropdown.disabled = True
        self.image_display_area.clear_output(wait=False)
        self.solution_output_area.clear_output(wait=False)

        self.fragment_comb_dropdown.options = []
        self.fragment_comb_dropdown.value = None
        self.fragment_comb_dropdown.disabled = True
        self.prev_smiles_button.disabled = True
        self.next_smiles_button.disabled = True
        self.smiles_pagination_label.value = "0 of 0"
        self.sort_smiles_button.disabled = True
        self.smiles_display_area.clear_output(wait=False)
        with self.smiles_display_area:
            logger.info("[GUI] Perform an action (Run/Display) to populate this area.")

    def run_retrosynthesis_on_click(self, b: widgets.Button) -> None:
        """Handles the click event for the 'Run Retrosynthesis' button."""
        self.state.reset_run_state()
        self.reset_ui_outputs()  # Reset UI elements

        self.output_area.clear_output(wait=True)  # Clear main output specifically for run
        with self.output_area:
            logger.info("[GUI] Starting retrosynthesis...")

        target: str = self.target_smiles_input.value
        fragmenter_name: str = self.fragmenter_choice.value
        json_path_str: str = self.file_path_input.value
        parallelize: bool = self.parallelize_checkbox.value
        num_cores: int | None = self.num_cores_input.value  # type: ignore
        core_factor: int = self.core_factor_input.value  # type: ignore

        if not target:
            with self.output_area:
                logger.error("[GUI] ERROR: Target SMILES cannot be empty.")
            return

        if json_path_str:
            mol_properties_path = Path(json_path_str)
            if not mol_properties_path.is_file():
                with self.output_area:
                    logger.error(f"[GUI] ERROR: Properties file not found at {mol_properties_path}")
                return
        try:
            logger.info("[GUI] Running fragmentation...")
            fragmenter: Fragmenter | None = None
            if fragmenter_name == "BRICSFragmenter":
                fragmenter = BRICSFragmenter(target)
            elif fragmenter_name == "rBRICSFragmenter":
                fragmenter = rBRICSFragmenter(target)
            else:
                with self.output_area:
                    logger.error(f"[GUI] ERROR: Unknown fragmenter type '{fragmenter_name}'")
                return

            with self.output_area:
                logger.info(f"[GUI] Using Fragmenter: {fragmenter_name}")
                logger.info(f"[GUI] Using Properties: {mol_properties_path}")
                logger.info(f"[GUI] Parallelize: {parallelize}, Num Cores: {num_cores}, Core Factor: {core_factor}")

            retro_tool = Retrosynthesis(
                fragmenter,
                mol_properties_path=mol_properties_path,
                parallelize=parallelize,
                num_cores=num_cores,
                core_factor=core_factor,
            )
            with self.output_area:
                logger.info("[GUI] Running retrosynthesis...")
                retro_tool.fragment_retrosynthesis()
                retro_solution = RetrosynthesisSolution(retro_tool)
                retro_solution.fill_solutions()
                logger.info(f"[GUI] Found {len(retro_solution.solutions)} solution(s).")

            self.state.retro_solution = retro_solution
            self.state.retro_tool = retro_tool
            with self.output_area:
                logger.info("[GUI] Retrosynthesis complete. Ready to display solutions and browse fragment SMILES.")

        except Exception as e:
            with self.output_area:
                logger.error(f"[GUI] ERROR during retrosynthesis: {e}", exc_info=True)

    def update_fragment_comb_dropdown(self, solution: SolutionType | None) -> None:
        """Populates the fragment comb dropdown based on a single solution."""
        with contextlib.suppress(ValueError):
            self.fragment_comb_dropdown.unobserve(self.on_fragment_comb_select, names="value")

        if solution:
            self.fragment_comb_dropdown.options = [(str(comb), comb) for comb in solution]
            self.fragment_comb_dropdown.disabled = False
            self.smiles_display_area.clear_output(wait=False)
            logger.debug("[GUI] Select a fragment combination to view SMILES.")
        else:
            self.fragment_comb_dropdown.options = []
            self.fragment_comb_dropdown.disabled = True
            self.smiles_display_area.clear_output(wait=False)
            logger.debug("[GUI] No fragment combinations in the selected solution.")

        self.fragment_comb_dropdown.value = None
        self.prev_smiles_button.disabled = True
        self.next_smiles_button.disabled = True
        self.smiles_pagination_label.value = "0 of 0"
        self.state.selected_fragment_comb = None
        self.state.current_smiles_list = []
        self.state.current_smiles_index = 0

        self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")

    def update_smiles_display(self) -> None:
        """Updates the SMILES display area and pagination controls."""
        smiles_list: list[str] = self.state.current_smiles_list
        index: int = self.state.current_smiles_index
        total_smiles: int = len(smiles_list)

        with self.smiles_display_area:
            self.smiles_display_area.clear_output(wait=True)
            if not smiles_list:
                logger.info("[GUI] No SMILES found for this combination.")
                self.smiles_pagination_label.value = "0 of 0"
                self.prev_smiles_button.disabled = True
                self.next_smiles_button.disabled = True
                return

            current_smiles: str = smiles_list[index]
            try:
                mol = Chem.MolFromSmiles(current_smiles)
                if mol:
                    img: PILImage = Draw.MolToImage(mol, size=(300, 300))
                    bio = io.BytesIO()
                    img.save(bio, format="PNG")
                    image_widget = widgets.Image(value=bio.getvalue(), format="png", width=300, height=300)
                    display(image_widget)  # type: ignore
                else:
                    logger.warning(f"[GUI] Invalid SMILES: {current_smiles}")
            except Exception as e:
                logger.error(f"[GUI] Error generating image for SMILES {current_smiles}: {e}")

        self.smiles_pagination_label.value = f"{index + 1} of {total_smiles}"
        self.prev_smiles_button.disabled = index == 0
        self.next_smiles_button.disabled = index >= total_smiles - 1

    def on_fragment_comb_select(self, change: dict[str, str | CombType]) -> None:
        """Handles selection changes in the fragment combination dropdown."""
        if change.get("type") == "change" and change.get("name") == "value":
            selected_comb: CombType = cast(CombType, change.get("new"))
            retro_tool: Retrosynthesis | None = self.state.retro_tool

            if selected_comb is None or retro_tool is None or not hasattr(retro_tool, "comb_bbs_dict"):
                self.state.reset_smiles_viewer_state()
                self.sort_smiles_button.disabled = True
            else:
                smiles_data: set[str] = retro_tool.comb_bbs_dict.get(selected_comb, set())
                smiles_list: list[str] = list(smiles_data)
                self.state.selected_fragment_comb = selected_comb
                self.state.current_smiles_list = smiles_list
                self.state.current_smiles_index = 0
                self.state.is_smiles_sorted = False
                self.sort_smiles_button.disabled = not smiles_list

            self.update_smiles_display()

    def on_prev_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Previous' SMILES button."""
        if self.state.current_smiles_index > 0:
            self.state.current_smiles_index -= 1
            self.update_smiles_display()

    def on_next_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Next' SMILES button."""
        smiles_list: list[str] = self.state.current_smiles_list
        if self.state.current_smiles_index < len(smiles_list) - 1:
            self.state.current_smiles_index += 1
            self.update_smiles_display()

    def on_sort_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Sort by Heavy Atoms' button."""
        if not self.state.is_smiles_sorted and self.state.current_smiles_list:
            self.state.current_smiles_list = sort_by_heavy_atoms(self.state.current_smiles_list)
            self.state.is_smiles_sorted = True
            self.state.current_smiles_index = 0
            self.update_smiles_display()
            self.sort_smiles_button.disabled = True

    def display_solutions_on_click(self, b: widgets.Button) -> None:
        """Handles the click event for the 'Display Solutions' button."""
        self.reset_ui_outputs()  # Reset UI elements
        self.state.reset_display_state()  # Reset backend display state
        with self.solution_output_area:  # Log specifically to solution output area after clear
            logger.debug("[GUI] Attempting to display solutions...")

        retro_solution: RetrosynthesisSolution | None = self.state.retro_solution
        use_filter: bool = self.filter_checkbox.value
        fragment_count: int | None = self.fragment_count_input.value if use_filter else None  # type: ignore

        if retro_solution is None:
            msg = "No retrosynthesis results available. Please run retrosynthesis first."
            with self.solution_output_area:
                logger.warning(f"[GUI] {msg}")
            return

        filtered_solutions: list[SolutionType] = []
        if use_filter and (fragment_count is None or not isinstance(fragment_count, int) or fragment_count <= 0):
            msg = "Filter checkbox is checked, but fragment count is invalid. Displaying all solutions."
            with self.solution_output_area:
                logger.warning(f"[GUI] {msg}")
            filtered_solutions = retro_solution.solutions
        elif use_filter:
            filtered_solutions = [sol for sol in retro_solution.solutions if len(sol) == fragment_count]
            msg = f"Filtering for solutions with exactly {fragment_count} fragments."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
        else:
            msg = "Filter checkbox is unchecked. Displaying all available solutions."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            filtered_solutions = retro_solution.solutions

        if not filtered_solutions:
            msg = f"No solutions found matching the criteria (count: {fragment_count})."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            return

        with self.solution_output_area:
            logger.info(f"[GUI] Visualizing {len(filtered_solutions)} solution(s)...")

        try:
            solution_images: list[PILImage] = retro_solution.visualize_solutions(filtered_solutions)
            if not solution_images:
                msg = "Visualization did not produce any images."
                with self.solution_output_area:
                    logger.info(f"[GUI] {msg}")
                return

            valid_images: list[PILImage] = []
            displayable_solutions: list[SolutionType] = []
            original_indices: list[int] = []
            for i, img in enumerate(solution_images):
                if img:
                    valid_images.append(img)
                    current_filtered_solution = filtered_solutions[i]
                    displayable_solutions.append(current_filtered_solution)
                    try:
                        original_solution_index = retro_solution.solutions.index(current_filtered_solution)
                        original_indices.append(original_solution_index)
                    except ValueError:
                        with self.solution_output_area:
                            logger.error(f"[GUI] Could not find filtered solution {i} in original solutions list.")
                        original_indices.append(-1)
                else:
                    with self.solution_output_area:
                        logger.warning(
                            f"[GUI] Null image returned by visualize_solutions for filtered solution index {i}"
                        )

            num_valid_images = len(valid_images)
            self.state.valid_images_cache = valid_images
            self.state.displayable_solutions = displayable_solutions

            if num_valid_images == 0:
                msg = "Visualization did not produce any valid images."
                with self.solution_output_area:
                    logger.info(f"[GUI] {msg}")
                return

            with self.solution_output_area:
                logger.info(f"[GUI] Generated {num_valid_images} image(s). Use dropdown to view.")

            dropdown_options: list[tuple[str, int]] = [
                (f"Solution {original_indices[i] + 1}", i) for i in range(num_valid_images) if original_indices[i] != -1
            ]
            self.solution_dropdown.options = dropdown_options
            self.solution_dropdown.value = 0 if dropdown_options else None
            self.solution_dropdown.disabled = not dropdown_options
            self.solution_dropdown.observe(self.on_solution_select, names="value")

            if num_valid_images > 0 and self.state.displayable_solutions:
                initial_solution: SolutionType = self.state.displayable_solutions[0]
                self.update_fragment_comb_dropdown(initial_solution)
            else:
                self.update_fragment_comb_dropdown(None)

            if valid_images:
                self.image_display_area.clear_output(wait=True)
                with self.image_display_area:
                    display(valid_images[0])  # type: ignore

            with self.solution_output_area:
                logger.info("[GUI] Solutions displayed.")

        except Exception as e:
            with self.solution_output_area:
                logger.error(f"[GUI] An error occurred during solution visualization: {e}", exc_info=True)

    def on_solution_select(self, change: dict[str, str | int]) -> None:
        """Callback function for solution dropdown selection changes."""
        if change.get("type") == "change" and change.get("name") == "value":
            selected_index: int = cast(int, change.get("new"))
            valid_images_cache: list[PILImage] = self.state.valid_images_cache
            displayable_solutions: list[SolutionType] = self.state.displayable_solutions

            if selected_index is not None and 0 <= selected_index < len(valid_images_cache):
                self.image_display_area.clear_output(wait=True)
                with self.image_display_area:
                    display(valid_images_cache[selected_index])  # type: ignore

                if selected_index < len(displayable_solutions):
                    selected_solution: SolutionType = displayable_solutions[selected_index]
                    self.update_fragment_comb_dropdown(selected_solution)
                    self.state.reset_smiles_viewer_state()
                else:
                    with self.solution_output_area:
                        logger.warning(
                            f"[GUI] Selected index {selected_index} out of bounds for displayable_solutions."
                        )
                    self.update_fragment_comb_dropdown(None)
                    self.state.reset_smiles_viewer_state()

            elif selected_index is None:
                self.image_display_area.clear_output(wait=True)
                with self.solution_output_area:
                    logger.info("[GUI] No solution selected or available.")
                self.update_fragment_comb_dropdown(None)
                self.state.reset_smiles_viewer_state()
                self.sort_smiles_button.disabled = True

register_event_handlers()

Connects widget events to controller methods.

Source code in src/fragmentretro/app/gui/controller.py
def register_event_handlers(self) -> None:
    """Connects widget events to controller methods."""
    self.parallelize_checkbox.observe(self.handle_parallelize_change, names="value")
    self.filter_checkbox.observe(self.handle_filter_change, names="value")
    self.run_button.on_click(self.run_retrosynthesis_on_click)
    self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")
    self.prev_smiles_button.on_click(self.on_prev_smiles_click)
    self.next_smiles_button.on_click(self.on_next_smiles_click)
    self.sort_smiles_button.on_click(self.on_sort_smiles_click)
    self.display_button.on_click(self.display_solutions_on_click)
    self.solution_dropdown.observe(self.on_solution_select, names="value")

handle_parallelize_change(change)

Callback to enable/disable core inputs based on parallelize checkbox.

Source code in src/fragmentretro/app/gui/controller.py
def handle_parallelize_change(self, change: dict[str, bool]) -> None:
    """Callback to enable/disable core inputs based on parallelize checkbox."""
    is_parallel = change.get("new", False)
    self.num_cores_input.disabled = not is_parallel
    self.core_factor_input.disabled = not is_parallel

handle_filter_change(change)

Callback to enable/disable fragment count input based on filter checkbox.

Source code in src/fragmentretro/app/gui/controller.py
def handle_filter_change(self, change: dict[str, bool]) -> None:
    """Callback to enable/disable fragment count input based on filter checkbox."""
    is_filtered = change.get("new", False)
    self.fragment_count_input.disabled = not is_filtered
    if not is_filtered:
        self.fragment_count_input.value = None  # type: ignore

reset_ui_outputs()

Resets output areas, dropdowns, and buttons to their initial states.

Source code in src/fragmentretro/app/gui/controller.py
def reset_ui_outputs(self) -> None:
    """Resets output areas, dropdowns, and buttons to their initial states."""
    self.solution_dropdown.options = []
    self.solution_dropdown.value = None
    self.solution_dropdown.disabled = True
    self.image_display_area.clear_output(wait=False)
    self.solution_output_area.clear_output(wait=False)

    self.fragment_comb_dropdown.options = []
    self.fragment_comb_dropdown.value = None
    self.fragment_comb_dropdown.disabled = True
    self.prev_smiles_button.disabled = True
    self.next_smiles_button.disabled = True
    self.smiles_pagination_label.value = "0 of 0"
    self.sort_smiles_button.disabled = True
    self.smiles_display_area.clear_output(wait=False)
    with self.smiles_display_area:
        logger.info("[GUI] Perform an action (Run/Display) to populate this area.")

run_retrosynthesis_on_click(b)

Handles the click event for the 'Run Retrosynthesis' button.

Source code in src/fragmentretro/app/gui/controller.py
def run_retrosynthesis_on_click(self, b: widgets.Button) -> None:
    """Handles the click event for the 'Run Retrosynthesis' button."""
    self.state.reset_run_state()
    self.reset_ui_outputs()  # Reset UI elements

    self.output_area.clear_output(wait=True)  # Clear main output specifically for run
    with self.output_area:
        logger.info("[GUI] Starting retrosynthesis...")

    target: str = self.target_smiles_input.value
    fragmenter_name: str = self.fragmenter_choice.value
    json_path_str: str = self.file_path_input.value
    parallelize: bool = self.parallelize_checkbox.value
    num_cores: int | None = self.num_cores_input.value  # type: ignore
    core_factor: int = self.core_factor_input.value  # type: ignore

    if not target:
        with self.output_area:
            logger.error("[GUI] ERROR: Target SMILES cannot be empty.")
        return

    if json_path_str:
        mol_properties_path = Path(json_path_str)
        if not mol_properties_path.is_file():
            with self.output_area:
                logger.error(f"[GUI] ERROR: Properties file not found at {mol_properties_path}")
            return
    try:
        logger.info("[GUI] Running fragmentation...")
        fragmenter: Fragmenter | None = None
        if fragmenter_name == "BRICSFragmenter":
            fragmenter = BRICSFragmenter(target)
        elif fragmenter_name == "rBRICSFragmenter":
            fragmenter = rBRICSFragmenter(target)
        else:
            with self.output_area:
                logger.error(f"[GUI] ERROR: Unknown fragmenter type '{fragmenter_name}'")
            return

        with self.output_area:
            logger.info(f"[GUI] Using Fragmenter: {fragmenter_name}")
            logger.info(f"[GUI] Using Properties: {mol_properties_path}")
            logger.info(f"[GUI] Parallelize: {parallelize}, Num Cores: {num_cores}, Core Factor: {core_factor}")

        retro_tool = Retrosynthesis(
            fragmenter,
            mol_properties_path=mol_properties_path,
            parallelize=parallelize,
            num_cores=num_cores,
            core_factor=core_factor,
        )
        with self.output_area:
            logger.info("[GUI] Running retrosynthesis...")
            retro_tool.fragment_retrosynthesis()
            retro_solution = RetrosynthesisSolution(retro_tool)
            retro_solution.fill_solutions()
            logger.info(f"[GUI] Found {len(retro_solution.solutions)} solution(s).")

        self.state.retro_solution = retro_solution
        self.state.retro_tool = retro_tool
        with self.output_area:
            logger.info("[GUI] Retrosynthesis complete. Ready to display solutions and browse fragment SMILES.")

    except Exception as e:
        with self.output_area:
            logger.error(f"[GUI] ERROR during retrosynthesis: {e}", exc_info=True)

update_fragment_comb_dropdown(solution)

Populates the fragment comb dropdown based on a single solution.

Source code in src/fragmentretro/app/gui/controller.py
def update_fragment_comb_dropdown(self, solution: SolutionType | None) -> None:
    """Populates the fragment comb dropdown based on a single solution."""
    with contextlib.suppress(ValueError):
        self.fragment_comb_dropdown.unobserve(self.on_fragment_comb_select, names="value")

    if solution:
        self.fragment_comb_dropdown.options = [(str(comb), comb) for comb in solution]
        self.fragment_comb_dropdown.disabled = False
        self.smiles_display_area.clear_output(wait=False)
        logger.debug("[GUI] Select a fragment combination to view SMILES.")
    else:
        self.fragment_comb_dropdown.options = []
        self.fragment_comb_dropdown.disabled = True
        self.smiles_display_area.clear_output(wait=False)
        logger.debug("[GUI] No fragment combinations in the selected solution.")

    self.fragment_comb_dropdown.value = None
    self.prev_smiles_button.disabled = True
    self.next_smiles_button.disabled = True
    self.smiles_pagination_label.value = "0 of 0"
    self.state.selected_fragment_comb = None
    self.state.current_smiles_list = []
    self.state.current_smiles_index = 0

    self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")

update_smiles_display()

Updates the SMILES display area and pagination controls.

Source code in src/fragmentretro/app/gui/controller.py
def update_smiles_display(self) -> None:
    """Updates the SMILES display area and pagination controls."""
    smiles_list: list[str] = self.state.current_smiles_list
    index: int = self.state.current_smiles_index
    total_smiles: int = len(smiles_list)

    with self.smiles_display_area:
        self.smiles_display_area.clear_output(wait=True)
        if not smiles_list:
            logger.info("[GUI] No SMILES found for this combination.")
            self.smiles_pagination_label.value = "0 of 0"
            self.prev_smiles_button.disabled = True
            self.next_smiles_button.disabled = True
            return

        current_smiles: str = smiles_list[index]
        try:
            mol = Chem.MolFromSmiles(current_smiles)
            if mol:
                img: PILImage = Draw.MolToImage(mol, size=(300, 300))
                bio = io.BytesIO()
                img.save(bio, format="PNG")
                image_widget = widgets.Image(value=bio.getvalue(), format="png", width=300, height=300)
                display(image_widget)  # type: ignore
            else:
                logger.warning(f"[GUI] Invalid SMILES: {current_smiles}")
        except Exception as e:
            logger.error(f"[GUI] Error generating image for SMILES {current_smiles}: {e}")

    self.smiles_pagination_label.value = f"{index + 1} of {total_smiles}"
    self.prev_smiles_button.disabled = index == 0
    self.next_smiles_button.disabled = index >= total_smiles - 1

on_fragment_comb_select(change)

Handles selection changes in the fragment combination dropdown.

Source code in src/fragmentretro/app/gui/controller.py
def on_fragment_comb_select(self, change: dict[str, str | CombType]) -> None:
    """Handles selection changes in the fragment combination dropdown."""
    if change.get("type") == "change" and change.get("name") == "value":
        selected_comb: CombType = cast(CombType, change.get("new"))
        retro_tool: Retrosynthesis | None = self.state.retro_tool

        if selected_comb is None or retro_tool is None or not hasattr(retro_tool, "comb_bbs_dict"):
            self.state.reset_smiles_viewer_state()
            self.sort_smiles_button.disabled = True
        else:
            smiles_data: set[str] = retro_tool.comb_bbs_dict.get(selected_comb, set())
            smiles_list: list[str] = list(smiles_data)
            self.state.selected_fragment_comb = selected_comb
            self.state.current_smiles_list = smiles_list
            self.state.current_smiles_index = 0
            self.state.is_smiles_sorted = False
            self.sort_smiles_button.disabled = not smiles_list

        self.update_smiles_display()

on_prev_smiles_click(b)

Handles clicks on the 'Previous' SMILES button.

Source code in src/fragmentretro/app/gui/controller.py
def on_prev_smiles_click(self, b: widgets.Button) -> None:
    """Handles clicks on the 'Previous' SMILES button."""
    if self.state.current_smiles_index > 0:
        self.state.current_smiles_index -= 1
        self.update_smiles_display()

on_next_smiles_click(b)

Handles clicks on the 'Next' SMILES button.

Source code in src/fragmentretro/app/gui/controller.py
def on_next_smiles_click(self, b: widgets.Button) -> None:
    """Handles clicks on the 'Next' SMILES button."""
    smiles_list: list[str] = self.state.current_smiles_list
    if self.state.current_smiles_index < len(smiles_list) - 1:
        self.state.current_smiles_index += 1
        self.update_smiles_display()

on_sort_smiles_click(b)

Handles clicks on the 'Sort by Heavy Atoms' button.

Source code in src/fragmentretro/app/gui/controller.py
def on_sort_smiles_click(self, b: widgets.Button) -> None:
    """Handles clicks on the 'Sort by Heavy Atoms' button."""
    if not self.state.is_smiles_sorted and self.state.current_smiles_list:
        self.state.current_smiles_list = sort_by_heavy_atoms(self.state.current_smiles_list)
        self.state.is_smiles_sorted = True
        self.state.current_smiles_index = 0
        self.update_smiles_display()
        self.sort_smiles_button.disabled = True

display_solutions_on_click(b)

Handles the click event for the 'Display Solutions' button.

Source code in src/fragmentretro/app/gui/controller.py
def display_solutions_on_click(self, b: widgets.Button) -> None:
    """Handles the click event for the 'Display Solutions' button."""
    self.reset_ui_outputs()  # Reset UI elements
    self.state.reset_display_state()  # Reset backend display state
    with self.solution_output_area:  # Log specifically to solution output area after clear
        logger.debug("[GUI] Attempting to display solutions...")

    retro_solution: RetrosynthesisSolution | None = self.state.retro_solution
    use_filter: bool = self.filter_checkbox.value
    fragment_count: int | None = self.fragment_count_input.value if use_filter else None  # type: ignore

    if retro_solution is None:
        msg = "No retrosynthesis results available. Please run retrosynthesis first."
        with self.solution_output_area:
            logger.warning(f"[GUI] {msg}")
        return

    filtered_solutions: list[SolutionType] = []
    if use_filter and (fragment_count is None or not isinstance(fragment_count, int) or fragment_count <= 0):
        msg = "Filter checkbox is checked, but fragment count is invalid. Displaying all solutions."
        with self.solution_output_area:
            logger.warning(f"[GUI] {msg}")
        filtered_solutions = retro_solution.solutions
    elif use_filter:
        filtered_solutions = [sol for sol in retro_solution.solutions if len(sol) == fragment_count]
        msg = f"Filtering for solutions with exactly {fragment_count} fragments."
        with self.solution_output_area:
            logger.info(f"[GUI] {msg}")
    else:
        msg = "Filter checkbox is unchecked. Displaying all available solutions."
        with self.solution_output_area:
            logger.info(f"[GUI] {msg}")
        filtered_solutions = retro_solution.solutions

    if not filtered_solutions:
        msg = f"No solutions found matching the criteria (count: {fragment_count})."
        with self.solution_output_area:
            logger.info(f"[GUI] {msg}")
        return

    with self.solution_output_area:
        logger.info(f"[GUI] Visualizing {len(filtered_solutions)} solution(s)...")

    try:
        solution_images: list[PILImage] = retro_solution.visualize_solutions(filtered_solutions)
        if not solution_images:
            msg = "Visualization did not produce any images."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            return

        valid_images: list[PILImage] = []
        displayable_solutions: list[SolutionType] = []
        original_indices: list[int] = []
        for i, img in enumerate(solution_images):
            if img:
                valid_images.append(img)
                current_filtered_solution = filtered_solutions[i]
                displayable_solutions.append(current_filtered_solution)
                try:
                    original_solution_index = retro_solution.solutions.index(current_filtered_solution)
                    original_indices.append(original_solution_index)
                except ValueError:
                    with self.solution_output_area:
                        logger.error(f"[GUI] Could not find filtered solution {i} in original solutions list.")
                    original_indices.append(-1)
            else:
                with self.solution_output_area:
                    logger.warning(
                        f"[GUI] Null image returned by visualize_solutions for filtered solution index {i}"
                    )

        num_valid_images = len(valid_images)
        self.state.valid_images_cache = valid_images
        self.state.displayable_solutions = displayable_solutions

        if num_valid_images == 0:
            msg = "Visualization did not produce any valid images."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            return

        with self.solution_output_area:
            logger.info(f"[GUI] Generated {num_valid_images} image(s). Use dropdown to view.")

        dropdown_options: list[tuple[str, int]] = [
            (f"Solution {original_indices[i] + 1}", i) for i in range(num_valid_images) if original_indices[i] != -1
        ]
        self.solution_dropdown.options = dropdown_options
        self.solution_dropdown.value = 0 if dropdown_options else None
        self.solution_dropdown.disabled = not dropdown_options
        self.solution_dropdown.observe(self.on_solution_select, names="value")

        if num_valid_images > 0 and self.state.displayable_solutions:
            initial_solution: SolutionType = self.state.displayable_solutions[0]
            self.update_fragment_comb_dropdown(initial_solution)
        else:
            self.update_fragment_comb_dropdown(None)

        if valid_images:
            self.image_display_area.clear_output(wait=True)
            with self.image_display_area:
                display(valid_images[0])  # type: ignore

        with self.solution_output_area:
            logger.info("[GUI] Solutions displayed.")

    except Exception as e:
        with self.solution_output_area:
            logger.error(f"[GUI] An error occurred during solution visualization: {e}", exc_info=True)

on_solution_select(change)

Callback function for solution dropdown selection changes.

Source code in src/fragmentretro/app/gui/controller.py
def on_solution_select(self, change: dict[str, str | int]) -> None:
    """Callback function for solution dropdown selection changes."""
    if change.get("type") == "change" and change.get("name") == "value":
        selected_index: int = cast(int, change.get("new"))
        valid_images_cache: list[PILImage] = self.state.valid_images_cache
        displayable_solutions: list[SolutionType] = self.state.displayable_solutions

        if selected_index is not None and 0 <= selected_index < len(valid_images_cache):
            self.image_display_area.clear_output(wait=True)
            with self.image_display_area:
                display(valid_images_cache[selected_index])  # type: ignore

            if selected_index < len(displayable_solutions):
                selected_solution: SolutionType = displayable_solutions[selected_index]
                self.update_fragment_comb_dropdown(selected_solution)
                self.state.reset_smiles_viewer_state()
            else:
                with self.solution_output_area:
                    logger.warning(
                        f"[GUI] Selected index {selected_index} out of bounds for displayable_solutions."
                    )
                self.update_fragment_comb_dropdown(None)
                self.state.reset_smiles_viewer_state()

        elif selected_index is None:
            self.image_display_area.clear_output(wait=True)
            with self.solution_output_area:
                logger.info("[GUI] No solution selected or available.")
            self.update_fragment_comb_dropdown(None)
            self.state.reset_smiles_viewer_state()
            self.sort_smiles_button.disabled = True