# MoleditPy Plugin Development Manual (Version 4.0)

> [!TIP]
> [Use this link to download the manual for offline reading or sharing with AI assistants.](https://hiroyokoyama.github.io/python_molecular_editor/docs/PLUGIN_DEVELOPMENT_MANUAL_V4.md)

> [!NOTE]
> This is the manual for the **Version 4.0** API. For the previous version, see the [Plugin Development Manual (Version 3.0)](old/PLUGIN_DEVELOPMENT_MANUAL_V3.md).

Welcome to the **Version 4.0** of the MoleditPy Plugin API. This version introduces a fully decoupled, namespaced architecture designed for high stability, clean memory management, and long-term maintainability.

> [!IMPORTANT]
> **API PHILOSOPHY**: In Version 4.0, it is strongly recommended that you avoid accessing the `MainWindow` directly via monkey-patching or unverified attributes. Instead, you should use the stable methods provided by the `PluginContext`. 
> 
> While we aim to maintain internal stability as much as possible, the application's core structure (e.g., manager names, attribute paths) may change significantly between major versions. By using the `PluginContext`, your plugin is protected by a stable abstraction layer that ensures long-term compatibility even when the core application is refactored.

---

## What's New in Version 4.0

Compared to Version 3.0, the Version 4.0 API introduces the following key architectural changes and additions:

1. **Decoupled Data Model**:
   - The core chemistry data model (`MolecularData`, `data.atoms`, `data.bonds`) has been fully decoupled from the 2D visual scene items (`AtomItem`, `BondItem`).
   - The `'item'` key has been removed from `data.atoms` and `data.bonds`. Visual scene items must now be accessed via `scene.atom_items` and `scene.bond_items` (or using the registry).
   - `data._next_atom_id` has been cleaned up and renamed to `data.next_atom_id`.

2. **New stable APIs on `PluginContext`**:
   - Added `enter_3d_mode()` as a clean, stable alias of `enter_3d_viewer_mode()` to switch the application layout to the 3D viewer.
   - Standardized scene update methods: direct calls to `scene.update_all_items()` and `scene.update_connected_bonds(atoms)` are now standard and preferred over hasattr-guarded checks.

3. **Memory and Signal Safety**:
   - Preemptive disconnect of event filters/actions will safely ignore errors at log `DEBUG` level to prevent unnecessary warning logs when signals are not yet bound.

---

## 1. Getting Started

A MoleditPy plugin is either a **single `.py` file** or a **folder package**. Both are placed in your **user plugin directory** and discovered automatically at startup.

**Plugin directory location:**
- **Windows**: `C:\Users\<YourName>\.moleditpy\plugins\`
- **Linux / macOS**: `~/.moleditpy/plugins/`

Create the folder if it does not exist, drop your `.py` file in, and restart the app (or use **Plugins > Reload All Plugins**).

### 1.1 Quick Start: The Minimal Plugin

The absolute smallest working plugin. Save as `~/.moleditpy/plugins/hello_world.py`:

```python
PLUGIN_NAME = "Hello World"

def initialize(context):
    context.add_menu_action("Tools/Say Hello", lambda: context.show_status_message("Hello!"))
```

Two lines. No imports needed for basic status messages. The plugin appears under **Tools > Say Hello** after the next reload.

### 1.2 Single-File vs. Folder Plugins

| | Single file (`my_plugin.py`) | Folder package (`MyPlugin/`) |
|---|---|---|
| **Best for** | Simple tools, one-file scripts | Complex tools with sub-modules or assets |
| **Entry point** | `my_plugin.py` | `MyPlugin/__init__.py` |
| **Imports** | Top-level only | Use relative imports (`from .logic import …`) |

Start with a single file. Promote to a folder package only when the file needs splitting.

### 1.3 Plugin Metadata
Define these at the top of your script. They are used for the UI and the internal registry.

| Variable | Description |
| :--- | :--- |
| `PLUGIN_NAME` | **(Required)** The human-readable name of the plugin. |
| `PLUGIN_VERSION` | Version string (e.g., `"1.0.2"` or `"2026.03.31"`). |
| `PLUGIN_AUTHOR` | Name of the developer. |
| `PLUGIN_DESCRIPTION` | A short summary shown in the Plugin Manager. |
| `PLUGIN_CATEGORY` | Optional category (e.g., `"Analysis"`, `"Visualization"`), used as the sub-menu for `run()`-based plugins. **Applies only when the plugin file sits directly in the plugins root.** If the user filed it inside a category folder, that folder wins — where they put it is their decision, not the plugin's. |
| `PLUGIN_SUPPORTED_MOLEDITPY_VERSION` | Optional: Version specifier matching the current MoleditPy app version (e.g., `"3.*"`, `">=3.5"`). |
| `PLUGIN_TAGS` | Optional: List of tags/categories (e.g., `["Utility", "Analysis"]`). |
| `PLUGIN_DEPENDENCIES` | Optional: List of package dependency strings the plugin **requires**. Supports standard PEP-508 version constraints (e.g. `["numpy>=1.20", "rdkit>=2022.03"]`) or packages without version constraints. |
| `PLUGIN_OPTIONAL_DEPENDENCIES` | Optional: Same format, for packages that unlock **extra features** but are not needed for the plugin to run (e.g. `["matplotlib>=3.5"]`). The Plugin Installer lists them separately and never warns about them when installing. |

### 1.4 Folder-based Plugins (Packages)
For complex plugins, use a folder structure. MoleditPy will treat the folder as a single plugin if it contains an `__init__.py`.

**Structure:**
```text
plugins/
  └── HighValuePlugin/      <-- Folder Name
       ├── __init__.py      <-- Entry point & Metadata (REQUIRED)
       ├── logic.py         <-- Sub-module
       └── assets/          <-- Icons or data
```

In your `__init__.py`, use **relative imports**:
```python
from .logic import process_data

PLUGIN_NAME = "High Value Tool"
PLUGIN_VERSION = "1.0"

def initialize(context):
    context.add_menu_action("Tools/Process", lambda: process_data(context))
```

---

## 2. Core API: The PluginContext Reference

The `context` object passed to `initialize(context)` is your safe proxy to the application's core logic.

### Quick Reference

| Category | Method / Property | Description |
|---|---|---|
| **UI** | `add_menu_action(path, cb, ...)` | Add item to any main menu |
| **UI** | `add_plugin_menu(path, cb, ...)` | Add item inside the Plugins menu |
| **UI** | `add_analysis_tool(label, cb)` | Add tool to the Analysis menu |
| **UI** | `add_export_action(label, cb)` | Add option to the Export menu |
| **UI** | `add_toolbar_action(cb, text, ...)` | Add button to the Plugin Toolbar |
| **UI** | `show_status_message(msg, ms)` | Temporary message in status bar |
| **UI** | `enter_3d_mode()` | Switch layout to 3D mode (alias of `enter_3d_viewer_mode`) |
| **UI** | `enter_3d_viewer_mode()` | Switch layout to 3D viewer mode |
| **UI** | `mark_project_modified()` | Mark project as modified and update the title bar |
| **UI** | `refresh_ui()` | Sync info panel, undo buttons, and title bar after an edit |
| **UI** | `set_3d_features_enabled(bool)` | Enable / disable the 3D panel and related actions |
| **UI** | `set_analysis_enabled(bool)` | Enable / disable the Analysis menu action |
| **Files** | `register_file_opener(ext, cb, priority)` | Handle a file extension (Import + CLI) |
| **Files** | `register_drop_handler(cb, priority)` | Handle drag-and-drop onto the window |
| **Files** | `set_current_file(path)` | Name *path* in the window title as the open file |
| **Files** | `get_current_file()` | Path the application currently has open, or `None` |
| **Molecule** | `current_molecule` | Get / set the active RDKit mol |
| **Molecule** | `load_from_smiles(smiles)` | Add molecule from SMILES string |
| **Molecule** | `show_xyz_data(xyz_text, source_name="XYZ data")` | Display XYZ text in the 3D viewer |
| **Molecule** | `to_xyz_block()` | Export current 3D coordinates as an XYZ block |
| **Molecule** | `get_selected_atom_indices()` | Indices of user-selected atoms |
| **Molecule** | `push_undo_checkpoint()` | Snapshot state to undo stack |
| **Molecule** | `clear_canvas(push_to_undo=True)` | Clear the 2D editor canvas |
| **Molecule** | `check_chemistry_problems()` | Trigger chemistry validation pass |
| **2D** | `refresh_2d_scene()` | Full 2D canvas redraw — recalculates rings and repaints all atoms/bonds |
| **3D** | `refresh_3d_view()` | Lightweight 3D redraw |
| **3D** | `draw_molecule_3d(mol)` | Full 3D scene rebuild |
| **3D** | `reset_3d_camera()` | Fit camera to molecule |
| **2D** | `fit_2d_view()` | Fit all 2D scene items into the 2D editor viewport |
| **3D** | `get_3d_controller()` | Atom/bond color overrides |
| **3D** | `register_3d_style(name, cb)` | Custom visualization mode |
| **3D** | `register_optimization_method(name, cb)` | Custom geometry optimizer |
| **3D** | `plotter` | Direct PyVista plotter access |
| **3D** | `register_atom_drag_handler(cb)` | Observe live 3D atom / group dragging |
| **3D** | `is_dragging_atom` | True while a 3D drag gesture is in progress |
| **Project** | `register_save_handler(cb)` | Save plugin data to `.pmeprj` |
| **Project** | `register_load_handler(cb)` | Restore plugin data from `.pmeprj` |
| **Project** | `register_document_reset_handler(cb)` | Reset on File > New |
| **Settings** | `get_setting(key, default)` | Read a persisted setting |
| **Settings** | `set_setting(key, value)` | Write a persisted setting |
| **Windows** | `register_window(id, win)` | Keep a Qt window alive |
| **Windows** | `get_window(id)` | Retrieve a registered window |
| **Access** | `get_main_window()` | Raw `MainWindow` (use sparingly) |
| **Access** | `scene` | Direct 2D `MoleculeScene` access |

---

### 2.1 UI & Feedback

The following methods allow your plugin to communicate with the user via the main interface.

#### `show_status_message(message, timeout=3000)`
Display a temporary message in the application's bottom status bar.
- **message** (`str`): The text to display.
- **timeout** (`int`): Duration in milliseconds before the message disappears. Default is 3000ms.

#### `add_menu_action(path, callback, text=None, icon=None, shortcut=None, pin=None)`
Register a custom item in the main menu. MoleditPy will automatically create any sub-menus defined in the path.
- **path** (`str`): The full menu path.
    - Use `File/My Action` to add to existing menus.
    - Use `MyPlugin/Action` to create a new top-level menu.
- **callback** (`Callable`): Function to execute when the action is triggered.
- **text** (`str`, optional): The label of the menu item. Defaults to the last part of `path`.
- **icon** (`str`, optional): Path to an image file or a standard icon name.
- **shortcut** (`str`, optional): Keyboard shortcut (e.g., `"Ctrl+Shift+X"`).
- **pin** (`str`, optional): Placement request — see [Pinning to the Plugin menu header](#pinning-to-the-plugin-menu-header) below. Added in 4.8.1.

> [!NOTE]
> `register_menu_action` is a deprecated alias kept for V2 compatibility. New plugins should always use `add_menu_action`.

##### Pinning to the Plugin menu header

The Plugin menu opens with `Plugin Manager...`, then a divider, then the installed plugins. A plugin that **manages the plugin system itself** — rather than acting on a molecule — belongs with the manager above that divider, not among the plugins it installs. Pass `pin="header"` to ask for that slot:

```python
context.add_menu_action("Plugin/My Installer...", open_installer, pin="header")
```

The request is granted only for a direct `Plugin/<entry>` path — a nested path such as `Plugin/Tools/My Installer...` is placed normally. Any other `pin` value is ignored, so unrecognised values stay forward compatible.

This is for plugin-management tools. An ordinary plugin that pins itself just crowds the header and pushes the real plugin list further down.

> [!WARNING]
> `pin` did not exist before 4.8.1, and passing it to an older MoleditPy raises `TypeError` out of `initialize()` — which drops your plugin entirely, not just its placement. If you support older releases, guard the call:
>
> ```python
> try:
>     context.add_menu_action("Plugin/My Installer...", open_installer, pin="header")
> except TypeError:
>     context.add_menu_action("Plugin/My Installer...", open_installer)
> ```

#### `add_plugin_menu(path, callback, text=None, icon=None, shortcut=None, pin=None)`
Register an action nested inside the **Plugins** menu. This is the preferred way to keep the main menu bar clean if your plugin has many tools.
- **path** (`str`): The sub-path within the Plugin menu (e.g., `"Utils/My Tool"`).
- **callback** (`Callable`): Function to execute.
- **text** (`str`, optional): Label for the action.
- **icon** (`str`, optional): Icon path.
- **shortcut** (`str`, optional): Keyboard shortcut.
- **pin** (`str`, optional): Placement request; see `add_menu_action`. Added in 4.8.1.

#### `add_toolbar_action(callback, text, icon=None, tooltip=None)`
Add a button to the dedicated **Plugin Toolbar**. The toolbar is hidden until at least one plugin registers an action — it appears automatically the first time this method is called.
- **callback** (`Callable`): Function to execute when clicked.
- **text** (`str`): Label for the button.
- **icon** (`str`, optional): Icon path.
- **tooltip** (`str`, optional): Hover text.

#### `add_analysis_tool(label, callback)`
Register a tool in the top-level **Analysis** menu. This is the preferred location for non-modifying data processing tools.
- **label** (`str`): Text to display in the menu.
- **callback** (`Callable`): Function to execute.

#### `add_export_action(label, callback)`
Register an action in the **Export** menu. Use this for custom file formats or data summaries.
- **label** (`str`): Text to display (e.g., `"Export as MyFormat..."`).
- **callback** (`Callable`): Function to execute.

#### `enter_3d_mode()`
Switch the application UI layout to 3D mode. This minimizes the 2D drawing canvas and maximizes the 3D viewer panel, while enabling all 3D interaction tools. This is a public API alias for `enter_3d_viewer_mode()`.

#### `enter_3d_viewer_mode()`
Switch the application UI layout to 3D viewer mode. Minimizes the 2D panel, maximizes the 3D scene, and activates 3D features.

#### `mark_project_modified()`
Signal to the application that the current project has unsaved changes. This updates the title bar with a modification indicator and sets the internal `has_unsaved_changes` flag.

> [!TIP]
> Call this after any operation that changes project-level state but does not go through the `current_molecule` setter (which already handles undo/redraw). For example, after saving calculation results or updating plugin-managed project data.

```python
def on_calculation_finished(self, result_data):
    # ... save result_data to plugin settings ...
    self.context.mark_project_modified()
```

#### `refresh_ui()`
Synchronize all UI chrome after modifying molecular data: updates the realtime-info panel, the undo/redo button states, and the window title bar in one call.

> [!TIP]
> Call this at the end of any edit that touches `scene.atom_items` / `scene.bond_items` directly instead of going through `context.current_molecule`. The three-line manual block `update_realtime_info(); update_undo_redo_actions(); update_window_title()` should always be replaced with this single call.

```python
# After manually adding atoms via scene.create_atom(...)
context.push_undo_checkpoint()
context.refresh_ui()
```

#### `set_3d_features_enabled(enabled)`
Enable or disable the 3D visualization panel and its related toolbar/menu actions.
- **enabled** (`bool`): `True` to show and activate 3D features, `False` to hide/disable them.

```python
# Disable 3D panel while showing a 2D-only result
context.set_3d_features_enabled(False)
```

#### `set_analysis_enabled(enabled)`
Enable or disable the **Analysis** menu action.
- **enabled** (`bool`): `True` to make the action clickable, `False` to grey it out.

```python
# Enable analysis only when a molecule is loaded
context.set_analysis_enabled(bool(context.current_molecule))
```

---

### 2.2 Files & Interoperability

These methods allow your plugin to handle external files and drag-and-drop events.

#### `register_file_opener(extension, callback, priority=0)`
Register a handler for opening a specific file type. Used for the **File > Import** menu and **Command Line** startup. If multiple plugins register the same extension, all appear in the Import menu as separate entries (e.g., "Import .xyz (Plugin A)"). For CLI/open commands, the plugin with the highest priority wins.
- **extension** (`str`): File extension including the dot (e.g., `".xyz"`, `".cub"`).
- **callback** (`Callable[[str], None]`): Function that receives the absolute file path and handles the loading logic.
- **priority** (`int`): Higher values run first. Use **negative values** (e.g., `-1`) for fallback handlers that only run when no other plugin claims the file.

```python
context.register_file_opener(".xyz", open_xyz)          # standard
context.register_file_opener(".xyz", my_opener, priority=100)   # takes precedence
context.register_file_opener(".xyz", fallback, priority=-1)     # last resort
```

#### `register_drop_handler(callback, priority=0)`
Register a handler for files dropped onto the main 2D/3D editor window. **This is distinct from `register_file_opener`** — drop handlers are only triggered by drag-and-drop, not by the Import menu or command-line file arguments. Register both if you need both paths.
- **callback** (`Callable[[str], bool]`): Function that receives the dropped file path. Must return `True` if it successfully handled the file, `False` to pass to the next handler.
- **priority** (`int`): Handlers with higher priority are checked first. Use negative values (e.g., `-1`) for fallback handlers that run only when nothing else claims the file.

#### `set_current_file(path)` / `get_current_file()`

Tell the application which file it now has open, and read it back. The window
title is built from this path — `job.out - MoleditPy Ver. 4.x` — exactly as it
is for a project file, so call `set_current_file()` at the end of a file opener
or drop handler.

> [!NOTE]
> Both were added in MoleditPy **4.7.1**. A plugin that also supports earlier
> 4.x hosts should guard the call:
> `if hasattr(context, "set_current_file"): context.set_current_file(path)`.

- **path** (`str | None`): Path to show. `None` retitles the window "Untitled".
- **returns** (`get_current_file`): The path, or `None` when nothing is open.

```python
def open_my_format(path):
    load(path)
    context.set_current_file(path)   # window now reads "sample.myext - MoleditPy ..."
```

> [!IMPORTANT]
> Assigning `mw.init_manager.current_file_path` yourself sets the value but does
> **not** repaint the title — it is rebuilt only on demand, so the window keeps
> naming the previous file. There is no `mw.current_file_path` attribute either;
> reading one always came back empty. Use these two methods.

Only the **File > Import** menu sets the path for you (the host does it around
the callback). A file opened from the command line, dropped onto the window, or
picked inside your own dialog does not, so a plugin that wants the file named
must call `set_current_file()` on those paths.

Saving is unaffected: **Ctrl+S** only writes back to a `.pmeprj`, so any other
extension recorded here falls through to *Save As* rather than being overwritten.

#### `register_atom_drag_handler(callback)`
Register a callback to receive real-time notifications during 3D atom or group dragging (single atom drag, group translate, group rotate).
- **callback** (`Callable[[str, List[int], Dict[int, Tuple[float, float, float]]], None]`): Function receiving `(event_type, atom_indices, positions)`.
  - `event_type`: `"start"`, `"move"`, or `"end"`.
  - `atom_indices`: List of RDKit atom indices being dragged.
  - `positions`: Dictionary mapping RDKit atom index to current `(x, y, z)` 3D tuple. Empty dict on `"start"`; on `"end"` it holds the final coordinates.

`"end"` is always delivered exactly once for every `"start"`, including when the gesture is cancelled or its mouse-release is lost. `"move"` is throttled to roughly 30 fps and is not emitted at all when the user disables **Real-time 3D Drag** in Settings, or when the molecule has more than 300 atoms (the per-frame scene rebuild is skipped for large structures). `"start"` and `"end"` are always emitted regardless.

```python
def on_atom_drag(event_type, indices, positions):
    if event_type == "move":
        print(f"Dragging atoms {indices}: {positions}")

context.register_atom_drag_handler(on_atom_drag)
```

#### `is_dragging_atom` (Property)
Check whether an atom or group is currently being dragged in the 3D viewport.
- **Returns** (`bool`): `True` if dragging is active, `False` otherwise.

---

### 2.3 Molecular State & Undo

Methods for interacting with the active molecule and managing the undo stack.

#### `current_molecule` (Property)
Get or set the active RDKit molecule object.
- **Getter**: Returns the `rdkit.Chem.Mol` currently loaded in the editor.
- **Setter**: Replaces the active molecule. Automatically triggers 2D/3D redrawing of the scene.

> [!NOTE]
> `current_mol` is a shorthand alias for `current_molecule` — both are available.

#### `load_from_smiles(smiles)`
Generate a 2D structure from a SMILES string and automatically add it to the 2D editor canvas.
- **smiles** (`str`): The SMILES string.

#### `show_xyz_data(xyz_text, source_name="XYZ data")`
Parse XYZ text, set it as the active molecule, switch to the 3D viewer, and draw it immediately.
- **xyz_text** (`str`): Either a full XYZ block with atom count/comment lines, or headerless atom rows in `symbol x y z` format.
- **source_name** (`str`): Optional label used in the status bar.
- **Returns** (`Optional[rdkit.Chem.Mol]`): The loaded molecule, or None if parsing fails.

Dummy or non-standard atom labels are loaded as RDKit dummy atoms (`*`). This includes labels such as `-`, `X`, `X:`, and any unrecognized element token. Dummy atoms keep their original token in the atom property `xyz_original_symbol`.

```python
xyz_text = """- 0.000 0.000 0.000
C 1.250 0.000 0.000
O 2.400 0.000 0.000
"""

mol = context.show_xyz_data(xyz_text, source_name="ORCA result")
if mol is None:
    context.show_status_message("Failed to display XYZ data.", 3000)
```

#### `to_xyz_block()`
Extracts the current 3D molecule structure and returns it as a string containing only the atomic elements and X, Y, Z coordinates. Omits standard XYZ headers (like atom count).
- **Returns** (`Optional[str]`): XYZ coordinate block, or None if no molecule is loaded.

#### `get_selected_atom_indices()`
Returns the RDKit indices of atoms currently selected by the user in either the 2D canvas or the 3D viewer.

#### `push_undo_checkpoint()`
Snapshots the current application state and adds it to the Undo history.
> [!IMPORTANT]
> **Usage Timing**: You should call this method **AFTER** you have finished modifying the molecule. The system only pushes a new state if it detects a difference from the previous one.

#### `clear_canvas(push_to_undo=True)`
Clear the 2D editor canvas, removing all atoms and bonds from the scene.
- **push_to_undo** (`bool`): Whether to push the cleared state onto the undo stack before clearing (default `True`). Pass `False` when you intend to immediately reconstruct a new molecule on the same undo level.

```python
# Clear and then draw new structure from SMILES
context.clear_canvas(push_to_undo=False)
# ... add atoms via scene API ...
context.push_undo_checkpoint()
```

#### `check_chemistry_problems()`
Trigger a chemistry validation pass on the current molecule. Updates problem flags on atoms (e.g., valence violations), which are reflected in the 2D view.

```python
# After a structural edit, validate chemistry
context.check_chemistry_problems()
context.refresh_ui()
```

#### `refresh_2d_scene()`
Force a full redraw of the 2D canvas. This recalculates ring geometry, then repaints every `AtomItem` and `BondItem` in the scene.

Use this after directly manipulating scene items (e.g., via `context.scene.create_atom()`) without going through the undo system or `context.current_molecule`. For a lightweight Qt repaint only (no geometry recalculation), use `context.scene.update()` instead.

```python
# After manually placing atoms on the scene, force a full visual refresh
context.push_undo_checkpoint()
context.refresh_2d_scene()
```

---

### 2.4 Lifecycle & Project Data

Manage how your plugin interacts with the application's overall lifecycle and the `.pmeprj` project format.

#### `register_save_handler(callback)`
Register a callback to save custom state into the application's project file.
- **callback** (`Callable[[], dict]`): Must return a dictionary of JSON-serializable data.

#### `register_load_handler(callback)`
Register a callback to restore custom state from the project file.
- **callback** (`Callable[[dict], None]`): Receives the dictionary previously saved by your save handler.

#### `register_document_reset_handler(callback)`
Register a callback to be invoked when a new document is created (**File > New**). Use this to reset your plugin's internal state when the user clears all data.
- **callback** (`Callable[[], None]`): Function with no arguments that resets plugin state.
- **Note**: This handler is called AFTER the main application has cleared all molecular data but BEFORE the "Cleared" message.

---

### 2.5 3D Visualization & Engine

These methods and properties allow your plugin to extend the core rendering and computational capabilities of MoleditPy.

#### `refresh_3d_view()`
Force the 3D window to redraw. Use this after performing minor visual changes (like color overrides) or manual coordinate updates.

#### `draw_molecule_3d(mol)`
Directly trigger a full redraw of the 3D scene using a specific RDKit molecule. This is more intensive than `refresh_3d_view()` as it rebuilds all 3D actors.
- **mol** (`rdkit.Chem.Mol`): The molecule to render.

#### `reset_3d_camera()`
Zoom in and re-center the 3D viewport to perfectly fit the current molecule.

#### `fit_2d_view()`
Fit all visible items in the 2D editor canvas into the viewport. Equivalent to the **Fit to View** toolbar action in the 2D editor.

#### `get_3d_controller()`
Returns a `Plugin3DController` instance (see Section 3). Use this for high-level visual overrides (atom/bond colors).

#### `register_3d_style(style_name, callback)`
Register a fully custom 3D visualization mode. This allows you to completely bypass the standard engine's rendering for specific research needs.
- **style_name** (`str`): Unique name for the style (e.g., `"vdw_surface"`).
- **callback** (`Callable[[MainWindow, rdkit.Chem.Mol], None]`): Function responsible for the entire drawing process. Access the PyVista plotter via `mw.plotter`.

#### `register_optimization_method(method_name, callback)`
Add a custom geometry optimizer. As soon as it is registered, the method appears **dynamically** in **two** places (no restart or manual menu rebuild required):
1. **`Settings ▸ 3D Optimization Settings`** — as a checkable entry alongside the built-in force fields (MMFF, UFF, …). Selecting it makes your method the persistent default used by the **Optimize 3D** button.
2. The **right-click menu of the "Optimize 3D" button** — for a one-off run without changing the persistent default.

- **method_name** (`str`): Name as it appears in the menu. The label is shown as-is; internally it is stored upper-cased as the method key.
- **callback** (`Callable[[rdkit.Chem.Mol], bool]`): Receives the current 3D RDKit molecule. Modify its conformer coordinates **in place** and return `True` on success or `False` on failure.

Behaviour notes:
- Registration is wired straight into the live UI: `register_optimization_method` calls into the main window's menu manager, so the entry shows up immediately for the running session. A full `rebuild_menus()` also preserves plugin methods.
- The callback runs **synchronously on the GUI thread** (unlike built-in force fields, which run on a worker thread) — keep it fast or drive your own progress UI.
- On success the app redraws the 3D view, pushes an undo state, and records the method as the last successful optimization.
- Exceptions are caught, logged with traceback, and reported in the status bar; they never crash the app.

#### `plotter` (Property)
Direct access to the `pyvista.Plotter` instance. Use for adding custom actors, text, or shapes to the 3D scene (e.g., `context.plotter.add_mesh(...)`).

---

### 2.6 Window & State Management (Namespaced)

Use the **Namespaced Registry** to keep your windows alive in memory and prevent ID collisions with other plugins.

| Method | Description |
| :--- | :--- |
| `register_window(id, window)` | Stores a Qt window/dialog. The ID is automatically prefixed with your `PLUGIN_NAME`. |
| `get_window(id)` | Retrieves your registered window by ID. Returns `None` if not found or if the window was deleted. |

---

### 2.7 Settings & Persistence

Plugins can store persistent settings that are saved in the global application configuration.

#### `get_setting(key, default=None)`
Retrieve a plugin-specific setting. The key is automatically namespaced by your plugin name.
- **key** (`str`): The setting name.
- **default** (`Any`): Value to return if the setting is not found.

#### `set_setting(key, value)`
Save a plugin-specific setting. These are saved to the user's disk when the application closes.
- **key** (`str`): The setting name.
- **value** (`Any`): The value to store (must be JSON-serializable).

> [!CAUTION]
> **Persistence Limit**: Settings saved via `set_setting` reside in the application's global `settings.json`. If the user triggers **"Reset All Settings"** via the main menu, these settings will be **REMOVED**.

#### Isolated Storage (Companion JSON)
If your plugin needs to persist data that must survive an application-wide reset, or if you have complex data structures, use a dedicated JSON file instead of `set_setting`.

The correct file name and location depend on your plugin type:

| Plugin type | File to use | Why it survives updates |
|---|---|---|
| **Single-file** (`my_plugin.py`) | `my_plugin.json` — same name, same folder | Installer only overwrites the `.py` file |
| **Folder** (`MyPlugin/__init__.py`) | `MyPlugin/settings.json` | Plugin Installer plugin backs up and restores `settings.json` before/after replacing the folder |

> [!IMPORTANT]
> For folder plugins, the file **must** be named exactly `settings.json`. The Plugin Installer plugin only preserves this specific filename during updates — any other name will be wiped.

**Single-file plugin** — name your JSON after the plugin file:

```python
import os, json

def _config_path():
    base = os.path.splitext(os.path.abspath(__file__))[0]
    return base + ".json"          # e.g. my_plugin.json next to my_plugin.py

def load_config():
    path = _config_path()
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return {"my_prop": 42}         # defaults

def save_config(data):
    with open(_config_path(), "w", encoding="utf-8") as f:
        json.dump(data, f, indent=4)
```

**Folder plugin** — always use `settings.json` in the package directory:

```python
import os, json

def _config_path():
    plugin_dir = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(plugin_dir, "settings.json")   # MyPlugin/settings.json

def load_config():
    path = _config_path()
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return {"my_prop": 42}         # defaults

def save_config(data):
    with open(_config_path(), "w", encoding="utf-8") as f:
        json.dump(data, f, indent=4)
```

---

## 3. Visual Overrides: Plugin3DController

Obtain a controller via `ctrl = context.get_3d_controller()`. These overrides are temporary visual changes that do not modify the actual RDKit molecule coordinates or properties.

- `set_atom_color(atom_index: int, color_hex: str)`: Override the color of a specific atom (e.g., `"#FF0000"` for red).
- `set_bond_color(bond_index: int, color_hex: str)`: Override the color of a specific RDKit bond by its index.
- `set_bond_color_by_atoms(idx1: int, idx2: int, color_hex: str)`: Helper to find and color the bond connecting two atom indices.

---

## 4. Modern Workflow Examples

These patterns come up repeatedly in real plugins. Each snippet shows the helper function and how it is wired into `initialize`.

### 4.1 The "Singleton Dialog" Pattern
Always check if your window is already open before creating a new one.

```python
PLUGIN_NAME = "My Tool"

def initialize(context):
    context.add_menu_action("Tools/Open Viewer", lambda: toggle_viewer(context))

def toggle_viewer(context):
    win = context.get_window("main_panel")
    if win:
        win.show()
        win.raise_()
        return

    win = MyCustomDialog(context.get_main_window())
    context.register_window("main_panel", win)
    win.show()
```

### 4.2 The "Modify-Then-Push" Undo Pattern
Correct ordering is essential for the Undo/Redo system to function.

```python
from rdkit.Chem import AllChem

PLUGIN_NAME = "Geometry Tools"

def initialize(context):
    context.add_menu_action("Tools/Center Molecule", lambda: center_molecule(context))

def center_molecule(context):
    mol = context.current_molecule
    if not mol:
        return

    # 1. Modify coordinates
    AllChem.ComputeCanonicalTransform(mol)

    # 2. Push back — this triggers the redraw
    context.current_molecule = mol

    # 3. Checkpoint AFTER setting (only saves if state changed)
    context.push_undo_checkpoint()

    context.show_status_message("Molecule centered.", 2000)
```

### 4.3 Highlighting Selection
Color atoms the user has selected.

```python
PLUGIN_NAME = "Selection Highlighter"

def initialize(context):
    context.add_menu_action("Tools/Highlight Selection", lambda: highlight_selection(context))

def highlight_selection(context):
    indices = context.get_selected_atom_indices()
    ctrl = context.get_3d_controller()

    for idx in indices:
        ctrl.set_atom_color(idx, "#00FF00")  # green

    context.refresh_3d_view()
```

---

## 5. Legacy vs. Modern API Quick Reference

| Legacy Pattern (Direct Access) | Modern Pattern (Best Practice) |
| :--- | :--- |
| `mw.statusBar().showMessage(m)` | `context.show_status_message(m)` |
| `mw.push_undo_state()` | `context.push_undo_checkpoint()` |
| `mw.current_mol` | `context.current_molecule` |
| `mw.plotter.reset_camera()` | `context.reset_3d_camera()` |
| `mw.my_tool = win` | `context.register_window("my_tool", win)` |
| `mw.draw_molecule_3d(...)` | `context.refresh_3d_view()` or `context.draw_molecule_3d(mol)` |
| `context.register_menu_action(path, text, cb)` | `context.add_menu_action(path, cb, text)` |
| `mw.settings.get(key)` | `context.get_setting(key)` |
| `mw.state_manager.has_unsaved_changes = True` + `mw.state_manager.update_window_title()` | `context.mark_project_modified()` |
| `mw.init_manager.current_file_path = p` (title never repaints) | `context.set_current_file(p)` |
| `mw.current_file_path` (not an attribute — always empty) | `context.get_current_file()` |
| `mw.state_manager.update_realtime_info()` + `mw.edit_actions_manager.update_undo_redo_actions()` + `mw.state_manager.update_window_title()` | `context.refresh_ui()` |
| `mw.view_3d_manager.fit_to_view()` | `context.fit_2d_view()` |
| `mw.edit_actions_manager.clear_2d_editor(push_to_undo=False)` | `context.clear_canvas(push_to_undo=False)` |
| `mw.ui_manager.enable_3d_features(enabled)` | `context.set_3d_features_enabled(enabled)` |
| `mw.init_manager.analysis_action.setEnabled(enabled)` | `context.set_analysis_enabled(enabled)` |
| `mw.compute_manager.check_chemistry_problems_fallback()` | `context.check_chemistry_problems()` |
| `mw.init_manager.scene.update_all_items()` | `context.refresh_2d_scene()` |
| `mw.view_3d_manager.draw_molecule_3d(mol)` | `context.draw_molecule_3d(mol)` |

> [!NOTE]
> `mw.trigger_conversion()` is still accessible via `context.get_main_window()` and delegates to the internal compute manager. Prefer `context.add_menu_action` or `context.add_analysis_tool` for triggering computation workflows from menus.

---

## 6. Advanced: MainWindow Internals (Low-Level)

While the `PluginContext` API covers most use cases, sometimes you need direct access to the `MainWindow` (`mw`) for specialized Qt or PyVista operations. Obtain it via `mw = context.get_main_window()`.

### 6.1 Core Proxy Properties (Convenience)
Several proxy properties on `mw` are available for convenience.

| Attribute / Method | Description |
| :--- | :--- |
| `mw.plotter` | Proxy for `mw.view_3d_manager.plotter`. Direct PyVista plotter access. |
| `mw.scene` | Proxy for `mw.init_manager.scene`. Direct 2D graphics scene access. |
| `mw.current_mol` | Proxy for `mw.view_3d_manager.current_mol`. The active RDKit molecule. |
| `mw.draw_molecule_3d(mol)` | Proxy method to trigger a full 3D redraw of the scene. |

### 6.2 The Managed Architecture
Most core logic is separated into specialized **Managers**. If a feature is not available as a proxy on `mw`, look in the corresponding manager.

| Attribute Path | Type | Description |
| :--- | :--- | :--- |
| `mw.init_manager.settings` | `dict` | Global application settings (stored in `settings.json`). |
| `mw.init_manager.splitter` | `QSplitter` | The UI divider between the 2D and 3D editor panels. |
| `mw.ui_manager` | `UIManager` | Handles status bar messages, editor modes, and UI state updates. |
| `mw.io_manager` | `IOManager` | Handles loading/saving molecules and project files. |
| `mw.compute_manager` | `ComputeManager` | Manages 3D coordinate conversion and background calculations. |
| `mw.view_3d_manager` | `View3DManager` | Manages the PyVista engine, styles, and 3D visualization. |
| `mw.state_manager` | `StateManager` | Manages the undo/redo stack and molecular data lifecycle. |

#### Example: Setting the Editor Mode
```python
mw = context.get_main_window()
mw.ui_manager.set_mode("atom_C")
```

#### Example: Triggering Coordinate Conversion (2D to 3D)
```python
mw = context.get_main_window()
mw.compute_manager.trigger_conversion()
```

### 6.3 Common Tasks (Quick Reference)

For operations not yet wrapped by `PluginContext`, access `mw` directly:

| Task | Code |
|---|---|
| Load a MOL/SDF file | `mw.io_manager.load_mol_file("path/to/file.mol")` |
| Load XYZ for 3D viewing only | `mw.io_manager.load_xyz_for_3d_viewing("path/to/file.xyz")` |
| Clear the 3D scene | `mw.plotter.clear()` |
| Switch the active 3D style | `mw.view_3d_manager.set_3d_style("ball_and_stick")` |
| Center the 3D camera | `mw.plotter.reset_camera()` |
| Show a status message | `mw.statusBar().showMessage("msg", 3000)` |
| Switch 2D editor mode | `mw.ui_manager.set_mode("atom_C")` |
| Trigger 2D→3D conversion | `mw.compute_manager.trigger_conversion()` |

---

## 7. Legacy Support (Compatibility Mode)

MoleditPy remains compatible with older plugin patterns, though they are considered deprecated.

### 7.1 The `run(mw)` Function

The simplest possible plugin with a `run` function. MoleditPy automatically adds an entry under the **Plugins** menu using `PLUGIN_NAME`. No `initialize` needed.

```python
# plugins/simple_counter.py
PLUGIN_NAME = "Atom Counter"
PLUGIN_VERSION = "1.0"

def run(main_window):
    """Called when the user clicks Plugins > Atom Counter."""
    mol = main_window.current_mol
    if mol is None:
        main_window.statusBar().showMessage("No molecule loaded.", 3000)
        return
    n = mol.GetNumAtoms()
    main_window.statusBar().showMessage(f"Atom count: {n}", 4000)
```

This is the fastest way to ship a one-action tool. The trade-off is that you get `main_window` directly instead of the stable `PluginContext` proxy. For new plugins, prefer `initialize(context)`.

> [!TIP]
> You can mix both: define `run(mw)` for the quick Plugins-menu entry **and** `initialize(context)` for additional menu actions or file handlers in the same file.

```python
# Mixing run() and initialize() in the same file
PLUGIN_NAME = "Bond Inspector"
PLUGIN_VERSION = "1.0"

def run(main_window):
    """Quick Plugins-menu action."""
    mol = main_window.current_mol
    if mol:
        main_window.statusBar().showMessage(f"Bonds: {mol.GetNumBonds()}", 3000)

def initialize(context):
    """Register extra menu entries."""
    context.add_analysis_tool("Bond Inspector", lambda: _show_bonds(context))

def _show_bonds(context):
    mol = context.current_molecule
    if mol:
        context.show_status_message(f"Bonds: {mol.GetNumBonds()}", 3000)
```

### 7.2 The `autorun(mw)` Function

Executed immediately at startup before the main window is shown. Use sparingly — slow `autorun` code delays application launch.

```python
PLUGIN_NAME = "Startup Logger"

def autorun(main_window):
    """Runs once at startup."""
    import logging
    logging.info("MoleditPy started — Startup Logger active.")
```

Prefer `initialize(context)` for all modern plugins. `autorun` has no equivalent in the modern API on purpose; startup side-effects belong inside `initialize`.

---

## 8. Best Practices & Troubleshooting

### 8.1 Thread Safety
**CRITICAL**: All plugin callbacks run in the Main UI Thread.
- **Fast operations** (< 100ms) are fine.
- **Slow operations** (heavy QM, large loops) will **freeze the entire application**.
- **Solution**: Use `QThread` or Python's `threading` for heavy work and return results via signals.

### 8.2 Error Handling
Always wrap your logic in `try...except` blocks. An uncaught exception in a plugin can cause the entire application to hang or crash silently.
```python
def my_callback(context):
    try:
        # unsafe logic
    except Exception as e:
        context.show_status_message(f"Plugin Error: {e}", 5000)
```

### 8.3 Memory Safety
Use `context.register_window()` for all persistent UI elements (dialogs, panels). If you don't register them, Python's garbage collector might delete the window while it's still being used by the user.

### 8.4 Atom Indices
MoleditPy uses **0-based RDKit indices**. These may differ from atom numbers in some chemical file formats. Always verify your mappings.

### 8.5 Hot Reloading
Use **Plugins > Reload All Plugins** to pick up code changes without restarting. Plugins survive reloads correctly as long as all persistent windows are registered via `context.register_window()`.

### 8.6 Logging & Debugging
Use Python's standard `logging` module for all diagnostics — **never `print()`** and **never a silent `except: pass`**. The host configures the root logger at startup, so a plugin only needs to call `logging` and its output is captured automatically.

```python
import logging

logger = logging.getLogger(__name__)  # optional; module-scoped logger

def my_callback(context):
    try:
        ...
    except Exception:
        logging.exception("MyPlugin: operation failed")  # ERROR + traceback
```

**Where the output goes**
- All records stream to **stdout**.
- When **Settings ▸ Other ▸ "Save log to file"** is enabled, records are also written to `~/.moleditpy/moleditpy.log` (rotating).
- **DEBUG-level** records are shown **only** when **Settings ▸ Other ▸ "Enable DEBUG level logging"** is enabled. Otherwise the threshold is `INFO`.

**Choosing a level** — pick the level by how much the user needs to know, so DEBUG mode stays useful and the normal log stays quiet:

| Level | Use for |
|-------|---------|
| `logging.debug()` | Non-important, best-effort failures you deliberately swallow (parse-skip of a malformed line, optional settings load, dialog teardown races). Replaces `except: pass`. |
| `logging.info()` | Notable lifecycle events (plugin loaded, file imported). |
| `logging.warning()` | Something recoverable that the user may care about. **Do not** downgrade these to DEBUG just to quiet the log. |
| `logging.error()` / `logging.exception()` | A real failure. `exception()` adds the traceback. **On app 4.3.0+ these also pop an error dialog** — see below. |

**Suppressing "best-effort" blocks at DEBUG level** — instead of a silent `except: pass`, route the swallowed exception to DEBUG so it stays diagnosable. Either call `logging.debug("...", exc_info=True)` directly, or use the host helper `suppress_log` (available since app **4.2.0**), a drop-in `contextlib.suppress` that logs the suppressed exception at DEBUG with a full traceback:

```python
from moleditpy.utils.suppress_log import suppress_log

with suppress_log(RuntimeError, AttributeError, note="teardown"):
    self._detach_event_filter()   # best-effort; failure is non-fatal but still logged at DEBUG
```

> If your plugin must also run against app versions **older than 4.2.0**, guard the import and fall back to `logging.debug(..., exc_info=True)`, since `suppress_log` will not exist there.

**Error records surface a dialog (app 4.3.0+)** — since **4.3.0** the host attaches a global handler that shows the user a modal **error dialog** for every `logging.error()`, `logging.exception()`, and `logging.critical()` record. `warning`/`info`/`debug` never do. Practical consequences for plugins:

- **Pick the level for the user, not just the log.** Use `error`/`exception` only for a genuine failure the user should be told about; use `logging.warning()` for something recoverable/background — it stays log-only, no dialog. (This is why a noisy `logging.error` in a redraw or polling loop is now user-visible.)
- **The dialog is deduped and non-blocking.** The same message is shown at most once per ~10 s (a fast repeat from a `QTimer` slot won't storm the user), it never blocks the event loop, and its *Details* expander shows the source `file:line` and traceback. Records logged off the GUI thread (e.g. from a `QThread` worker) stay log-only — a dialog there is unsafe.
- **Avoid a double dialog when you show your own.** If your plugin logs the error **and** shows its own `QMessageBox.critical`, **log first, then show your dialog** — the host defers its generic dialog one tick and suppresses it when it sees your modal already open. The reverse order (dialog first, then log) pops **two** dialogs.

```python
except Exception as e:
    logging.exception("MyPlugin: export failed")     # log FIRST
    QMessageBox.critical(self, "Export failed", str(e))  # your dialog suppresses the host's
```

- **Opt a record out of the dialog** (still logged) with the `no_dialog` extra — for a genuine error you want recorded but don't want to interrupt the user with:

```python
logging.error("MyPlugin: background sync failed", extra={"no_dialog": True})
```

> The `no_dialog` extra is harmless on older apps (<4.3.0), which simply ignore it — no version guard needed.

---

## 9. Complete Integrated Example

Combines menu registration, file import, atom selection, undo, and a singleton dialog.

```python
from rdkit import Chem
from PyQt6.QtWidgets import QMessageBox, QDockWidget, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import Qt

PLUGIN_NAME = "Research Assistant"
PLUGIN_VERSION = "3.1.0"

def initialize(context):
    context.add_menu_action("Research/Analyze Selection", lambda: analyze(context))
    context.add_menu_action("Research/Show Panel", lambda: show_panel(context))
    context.register_file_opener(".res", lambda path: load_res(path, context))

# --- Actions ---

def analyze(context):
    indices = context.get_selected_atom_indices()
    if not indices:
        context.show_status_message("Select atoms first.", 2000)
        return
    mol = context.current_molecule
    symbols = [mol.GetAtomWithIdx(i).GetSymbol() for i in indices]
    context.show_status_message(f"Selected: {', '.join(symbols)}", 4000)

def show_panel(context):
    dock = context.get_window("panel")
    if dock:
        dock.show()
        dock.raise_()
        return

    mw = context.get_main_window()
    dock = QDockWidget("Research Assistant", mw)
    content = QWidget()
    layout = QVBoxLayout(content)
    layout.addWidget(QLabel("Research Assistant active."))
    dock.setWidget(content)
    mw.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
    context.register_window("panel", dock)
    dock.show()

def load_res(path, context):
    try:
        with open(path, "r") as f:
            smiles = f.read().strip()
        mol = Chem.MolFromSmiles(smiles)
        if mol is None:
            raise ValueError("Could not parse SMILES in file.")
        context.current_molecule = mol
        context.push_undo_checkpoint()
        context.reset_3d_camera()
        context.show_status_message(f"Loaded {path}")
    except Exception as e:
        QMessageBox.critical(context.get_main_window(), "Load Error", str(e))
```

---

## 10. Cookbook & Examples

### 10.1 Analysis Tool: Molecular Weight
A tool that calculates properties and shows them in a popup. Use `context.current_molecule` and `context.show_status_message`.

```python
from rdkit.Chem import Descriptors
from PyQt6.QtWidgets import QMessageBox

PLUGIN_NAME = "Weight Calculator"

def initialize(context):
    def run_calc():
        mol = context.current_molecule
        if not mol:
            context.show_status_message("Error: No molecule loaded!", 2000)
            return

        weight = Descriptors.MolWt(mol)
        num_atoms = mol.GetNumAtoms()

        QMessageBox.information(
            context.get_main_window(),
            "Stats",
            f"Molecular Weight: {weight:.2f}\nAtoms: {num_atoms}"
        )

    context.add_analysis_tool("Show Molecular Weight", run_calc)
```

### 10.2 Custom 3D Component: Add Sphere
Use the `plotter` object to add persistent custom 3D geometries.

```python
import pyvista as pv

PLUGIN_NAME = "3D Decorator"

def initialize(context):
    context.add_menu_action("Visuals/Add Red Sphere", lambda: add_sphere(context))

def add_sphere(context):
    mw = context.get_main_window()
    plotter = mw.plotter

    # Create and add a sphere mesh
    sphere = pv.Sphere(radius=2.0)
    plotter.add_mesh(sphere, color="red", opacity=0.3, name="custom_plugin_sphere")

    context.refresh_3d_view()
    context.show_status_message("Sphere added to scene.")
```

### 10.3 Persistent Dock Panel (Singleton)
Use `register_window` to keep panels alive and manage their unique identity across the entire session.

```python
from PyQt6.QtWidgets import QDockWidget, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import Qt

PLUGIN_NAME = "Info Panel"

def initialize(context):
    context.add_menu_action("Tools/Show Info Panel", lambda: toggle_panel(context))

def toggle_panel(context):
    # 1. Check if the panel already exists in the manager
    dock = context.get_window("main_dock")
    if dock:
        dock.show()
        dock.raise_()
        return

    # 2. Create new panel if not found
    mw = context.get_main_window()
    dock = QDockWidget("Plugin Information", mw)
    content = QWidget()
    layout = QVBoxLayout(content)
    layout.addWidget(QLabel("Telemetry Active..."))
    dock.setWidget(content)

    mw.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)

    # 3. Register it so context.get_window() works next time
    context.register_window("main_dock", dock)
    dock.show()
```

### 10.4 Custom File Importer
Refactor your loaders to use `current_molecule` and `push_undo_checkpoint()` to ensure the user can revert the import.

```python
from rdkit import Chem

PLUGIN_NAME = "Simple SMILES Loader"

def initialize(context):
    context.register_file_opener(".smiles", lambda path: load_smiles(path, context))

def load_smiles(path, context):
    try:
        with open(path, 'r') as f:
            smiles = f.read().strip()

        mol = Chem.MolFromSmiles(smiles)
        if mol:
            mol = Chem.AddHs(mol)

            # 1. Update the app
            context.current_molecule = mol

            # 2. Save to Undo history (MANDATORY for importers)
            context.push_undo_checkpoint()

            # 3. Adjust View
            context.reset_3d_camera()
            context.show_status_message(f"Imported {path} successfully.")
    except Exception as e:
        context.show_status_message(f"Import Failed: {e}")
```

### 10.5 Custom 3D Style (Dynamic Overrides)
Custom styles should combine standard drawing with custom elements.

```python
PLUGIN_NAME = "High-Contrast Style"

def initialize(context):
    context.register_3d_style("High Contrast", draw_hc_style)

def draw_hc_style(mw, mol):
    # 1. Get the plotter from the window passed by the engine
    plotter = mw.plotter

    # 2. Add a background gradient or text
    plotter.set_background("black", top="gray")
    plotter.add_text("HIGH CONTRAST MODE", position='upper_left', color='yellow')

    # 3. Trigger standard draw with a style override
    # Note: Accessing view_3d_manager on mw is allowed in style callbacks
    mw.view_3d_manager.draw_standard_3d_style(mol, style_override='stick')
```

### 10.6 Custom Optimization Method
Optimization callbacks must modify the molecule **in-place**. Once registered, the method shows up immediately under `Settings ▸ 3D Optimization Settings` (as a selectable default) and in the **Optimize 3D** button's right-click menu — see [`register_optimization_method`](#register_optimization_methodmethod_name-callback) for the full behaviour.

```python
from rdkit.Chem import AllChem

PLUGIN_NAME = "Fast UFF Optimizer"

def initialize(context):
    context.register_optimization_method("Quick UFF", run_uff)

def run_uff(mol):
    """
    Called by the 'Optimize' logic.
    Modify 'mol' in-place. Return True on success.
    """
    if mol.GetNumAtoms() == 0:
        return False

    AllChem.UFFOptimizeMolecule(mol, maxIters=500)
    return True
```

### 10.7 Persistent User Preferences
Use `get_setting` and `set_setting` to remember simple user choices across sessions. For preferences that must survive an application-wide settings reset or plugin updates, use the companion JSON pattern from [Section 2.7](#27-settings--persistence) instead.

```python
PLUGIN_NAME = "Smart Labels"

def initialize(context):
    # Load user preference (default to True)
    show_labels = context.get_setting("show_labels", True)
    
    context.add_menu_action("Labels/Toggle", lambda: toggle_labels(context))
    context.show_status_message(f"Labels are {'on' if show_labels else 'off'}")

def toggle_labels(context):
    current = context.get_setting("show_labels", True)
    new_state = not current
    context.set_setting("show_labels", new_state)
    context.show_status_message(f"Labels {'on' if new_state else 'off'}")
    context.refresh_3d_view()
```

---

## 11. UI & UX Style Guide

To ensure your plugin feels like a native part of MoleditPy, follow these design principles:

### 11.1 Color Palette
MoleditPy uses a "Sleek Dark" theme. If you build custom widgets, use these QColor hints:
- **Background**: `QColor(30, 30, 35)`
- **Accent**: `QColor(0, 120, 215)`
- **Secondary Text**: `QColor(180, 180, 180)`

### 11.2 Icons
Prefer using the system's built-in icons where possible. If you provide your own, use **24x24 pixel PNGs** with transparency for toolbars.

---

## 12. Testing Your Plugins

You can test your plugin logic without running the full MoleditPy GUI by mocking the `PluginContext`.

```python
import unittest
from unittest.mock import MagicMock
from rdkit import Chem

class TestMyPlugin(unittest.TestCase):
    def test_logic(self):
        # 1. Create a mock context
        context = MagicMock()
        context.current_molecule = Chem.MolFromSmiles("CCO")

        # 2. Run your logic
        my_plugin_function(context)

        # 3. Verify side effects
        context.push_undo_checkpoint.assert_called_once()
```

---

## 13. Conclusion & Support

Thank you for contributing to the MoleditPy ecosystem! If you encounter any bugs or need new API features, please reach out via GitHub Issues.

**Happy Coding!**

---

*Documents the Version 4.0 plugin API as shipped in **MoleditPy 4.7.1** (2026-08-15).*
