Skip to content

Commit 95892f8

Browse files
authored
Merge pull request #42 from Faria22/copilot/add-save-settings-button
Add "Save Settings" action to menubar
2 parents 16455d7 + 0662791 commit 95892f8

File tree

4 files changed

+277
-11
lines changed

4 files changed

+277
-11
lines changed

src/moldenViz/_config_module.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@
99
import toml
1010
from pydantic import BaseModel, Field, field_validator
1111

12-
default_configs_dir = Path(__file__).parent / 'default_configs'
12+
# Global config directory paths
13+
DEFAULT_CONFIGS_DIR = Path(__file__).parent / 'default_configs'
14+
CUSTOM_CONFIGS_DIR = Path().home() / '.config/moldenViz'
15+
CUSTOM_CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
1316

14-
custom_configs_dir = Path().home() / '.config/moldenViz'
15-
custom_configs_dir.mkdir(parents=True, exist_ok=True)
17+
# Global config file paths
18+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIGS_DIR / 'config.toml'
19+
CUSTOM_CONFIG_PATH = CUSTOM_CONFIGS_DIR / 'config.toml'
20+
ATOM_TYPES_PATH = DEFAULT_CONFIGS_DIR / 'atom_types.json'
21+
22+
# Maintain backwards compatibility
23+
default_configs_dir = DEFAULT_CONFIGS_DIR
24+
custom_configs_dir = CUSTOM_CONFIGS_DIR
1625

1726

1827
class AtomType(BaseModel):
@@ -332,7 +341,7 @@ def load_atom_types(atoms_custom_config: dict) -> dict[int, AtomType]:
332341
dict[int, AtomType]
333342
A dictionary mapping atomic numbers to AtomType objects.
334343
"""
335-
with (default_configs_dir / 'atom_types.json').open('r') as f:
344+
with ATOM_TYPES_PATH.open('r') as f:
336345
atom_types_data = json.load(f)
337346

338347
# Validate and create AtomType objects using pydantic
@@ -387,11 +396,10 @@ def load_default_config() -> dict:
387396
FileNotFoundError
388397
If the default configuration file is not found.
389398
"""
390-
default_config_path = default_configs_dir / 'config.toml'
391-
if not default_config_path.exists():
392-
raise FileNotFoundError(f'Default configuration file not found at {default_config_path}. ')
399+
if not DEFAULT_CONFIG_PATH.exists():
400+
raise FileNotFoundError(f'Default configuration file not found at {DEFAULT_CONFIG_PATH}. ')
393401

394-
with default_config_path.open('r') as f:
402+
with DEFAULT_CONFIG_PATH.open('r') as f:
395403
return toml.load(f)
396404

397405
@staticmethod
@@ -403,9 +411,60 @@ def load_custom_config() -> dict:
403411
dict
404412
The custom configuration dictionary. Empty dict if file doesn't exist.
405413
"""
406-
custom_config_path = custom_configs_dir / 'config.toml'
407-
if not custom_config_path.exists():
414+
if not CUSTOM_CONFIG_PATH.exists():
408415
return {}
409416

410-
with custom_config_path.open('r') as f:
417+
with CUSTOM_CONFIG_PATH.open('r') as f:
411418
return toml.load(f)
419+
420+
def save_current_config(self) -> None:
421+
"""Save the current configuration to the custom config file.
422+
423+
This method writes the current configuration values to ~/.config/moldenViz/config.toml,
424+
preserving the TOML structure.
425+
"""
426+
# Build the configuration dict from the current values
427+
config_dict = {
428+
'smooth_shading': self.config.smooth_shading,
429+
'background_color': self.config.background_color,
430+
'grid': {
431+
'min_radius': self.config.grid.min_radius,
432+
'max_radius_multiplier': self.config.grid.max_radius_multiplier,
433+
'spherical': {
434+
'num_r_points': self.config.grid.spherical.num_r_points,
435+
'num_theta_points': self.config.grid.spherical.num_theta_points,
436+
'num_phi_points': self.config.grid.spherical.num_phi_points,
437+
},
438+
'cartesian': {
439+
'num_x_points': self.config.grid.cartesian.num_x_points,
440+
'num_y_points': self.config.grid.cartesian.num_y_points,
441+
'num_z_points': self.config.grid.cartesian.num_z_points,
442+
},
443+
},
444+
'MO': {
445+
'contour': self.config.mo.contour,
446+
'opacity': self.config.mo.opacity,
447+
'color_scheme': self.config.mo.color_scheme,
448+
},
449+
'molecule': {
450+
'opacity': self.config.molecule.opacity,
451+
'atom': {
452+
'show': self.config.molecule.atom.show,
453+
},
454+
'bond': {
455+
'show': self.config.molecule.bond.show,
456+
'max_length': self.config.molecule.bond.max_length,
457+
'color_type': self.config.molecule.bond.color_type,
458+
'color': self.config.molecule.bond.color,
459+
'radius': self.config.molecule.bond.radius,
460+
},
461+
},
462+
}
463+
464+
# Add custom_colors if set
465+
if self.config.mo.custom_colors is not None:
466+
config_dict['MO']['custom_colors'] = self.config.mo.custom_colors
467+
468+
# Write to file
469+
with CUSTOM_CONFIG_PATH.open('w') as f:
470+
toml.dump(config_dict, f)

src/moldenViz/plotter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,14 @@ def apply_bond_color_settings(self) -> None:
14551455
if redraw_molecule:
14561456
self.plotter.load_molecule(config)
14571457

1458+
def save_settings(self) -> None: # noqa: PLR6301
1459+
"""Save current configuration to the user's custom config file."""
1460+
try:
1461+
config.save_current_config()
1462+
messagebox.showinfo('Settings Saved', 'Configuration saved successfully to ~/.config/moldenViz/config.toml')
1463+
except (OSError, ValueError) as e:
1464+
messagebox.showerror('Save Error', f'Failed to save configuration: {e!s}')
1465+
14581466
def next_plot(self) -> None:
14591467
"""Advance to the next molecular orbital."""
14601468
self.current_orb_ind += 1

tests/test_config_save.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Unit tests for the configuration save functionality."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
import toml
7+
8+
from tests._src_imports import config_module
9+
10+
11+
def test_save_current_config_creates_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
12+
"""Test that save_current_config creates the config file."""
13+
# Temporarily change the CUSTOM_CONFIG_PATH to a temp directory
14+
test_config_dir = tmp_path / '.config' / 'moldenViz'
15+
test_config_dir.mkdir(parents=True, exist_ok=True)
16+
test_config_path = test_config_dir / 'config.toml'
17+
monkeypatch.setattr(config_module, 'CUSTOM_CONFIG_PATH', test_config_path)
18+
19+
# Create a config instance
20+
config = config_module.Config()
21+
22+
# Save the config
23+
config.save_current_config()
24+
25+
# Verify the file was created
26+
assert test_config_path.exists()
27+
28+
29+
def test_save_current_config_preserves_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
30+
"""Test that save_current_config correctly saves all configuration values."""
31+
# Temporarily change the CUSTOM_CONFIG_PATH to a temp directory
32+
test_config_dir = tmp_path / '.config' / 'moldenViz'
33+
test_config_dir.mkdir(parents=True, exist_ok=True)
34+
test_config_path = test_config_dir / 'config.toml'
35+
monkeypatch.setattr(config_module, 'CUSTOM_CONFIG_PATH', test_config_path)
36+
37+
# Create a config instance
38+
config = config_module.Config()
39+
40+
# Modify some values
41+
config.config.background_color = 'black'
42+
config.config.mo.contour = 0.2
43+
config.config.mo.opacity = 0.5
44+
config.config.grid.min_radius = 10
45+
config.config.molecule.bond.max_length = 5.0
46+
47+
# Save the config
48+
config.save_current_config()
49+
50+
# Load and verify the saved config
51+
with test_config_path.open('r') as f:
52+
saved_config = toml.load(f)
53+
54+
assert saved_config['background_color'] == 'black'
55+
assert saved_config['MO']['contour'] == 0.2
56+
assert saved_config['MO']['opacity'] == 0.5
57+
assert saved_config['grid']['min_radius'] == 10
58+
assert saved_config['molecule']['bond']['max_length'] == 5.0
59+
60+
61+
def test_save_current_config_with_custom_colors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
62+
"""Test that save_current_config correctly saves custom MO colors."""
63+
# Temporarily change the CUSTOM_CONFIG_PATH to a temp directory
64+
test_config_dir = tmp_path / '.config' / 'moldenViz'
65+
test_config_dir.mkdir(parents=True, exist_ok=True)
66+
test_config_path = test_config_dir / 'config.toml'
67+
monkeypatch.setattr(config_module, 'CUSTOM_CONFIG_PATH', test_config_path)
68+
69+
# Create a config instance
70+
config = config_module.Config()
71+
72+
# Set custom colors
73+
config.config.mo.custom_colors = ['blue', 'red']
74+
75+
# Save the config
76+
config.save_current_config()
77+
78+
# Load and verify the saved config
79+
with test_config_path.open('r') as f:
80+
saved_config = toml.load(f)
81+
82+
assert saved_config['MO']['custom_colors'] == ['blue', 'red']
83+
84+
85+
def test_save_current_config_without_custom_colors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
86+
"""Test that save_current_config doesn't include custom_colors when None."""
87+
# Temporarily change the CUSTOM_CONFIG_PATH to a temp directory
88+
test_config_dir = tmp_path / '.config' / 'moldenViz'
89+
test_config_dir.mkdir(parents=True, exist_ok=True)
90+
test_config_path = test_config_dir / 'config.toml'
91+
monkeypatch.setattr(config_module, 'CUSTOM_CONFIG_PATH', test_config_path)
92+
93+
# Create a config instance
94+
config = config_module.Config()
95+
96+
# Ensure custom_colors is None
97+
config.config.mo.custom_colors = None
98+
99+
# Save the config
100+
config.save_current_config()
101+
102+
# Load and verify the saved config
103+
with test_config_path.open('r') as f:
104+
saved_config = toml.load(f)
105+
106+
assert 'custom_colors' not in saved_config['MO']
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Integration test to verify Save Settings button functionality in plotter."""
2+
3+
from typing import Any
4+
from unittest.mock import MagicMock, patch
5+
6+
from tests._src_imports import plotter_module
7+
8+
9+
def test_save_settings_method_exists_in_orbital_selection_screen() -> None:
10+
"""Test that save_settings method exists in _OrbitalSelectionScreen."""
11+
# Access the private class through the module
12+
orbital_selection_screen = plotter_module._OrbitalSelectionScreen # noqa: SLF001
13+
14+
# Check that the method exists
15+
assert hasattr(orbital_selection_screen, 'save_settings')
16+
17+
# Verify it's a callable method
18+
assert callable(orbital_selection_screen.save_settings)
19+
20+
21+
@patch('moldenViz.plotter.messagebox')
22+
@patch('moldenViz.plotter.config')
23+
def test_save_settings_success(mock_config: Any, mock_messagebox: Any) -> None:
24+
"""Test that save_settings calls config.save_current_config and shows success message."""
25+
# Access the private class through the module
26+
orbital_selection_screen_class = plotter_module._OrbitalSelectionScreen # noqa: SLF001
27+
28+
# Set up mocks
29+
mock_config.save_current_config = MagicMock()
30+
31+
# Create a minimal instance (without full initialization)
32+
# We only need to test the save_settings method
33+
selection_screen = orbital_selection_screen_class.__new__(orbital_selection_screen_class)
34+
35+
# Call the method
36+
selection_screen.save_settings()
37+
38+
# Verify that save_current_config was called
39+
mock_config.save_current_config.assert_called_once()
40+
41+
# Verify that success message was shown
42+
mock_messagebox.showinfo.assert_called_once()
43+
args = mock_messagebox.showinfo.call_args[0]
44+
assert 'Settings Saved' in args[0]
45+
assert 'Configuration saved successfully' in args[1]
46+
47+
48+
@patch('moldenViz.plotter.messagebox')
49+
@patch('moldenViz.plotter.config')
50+
def test_save_settings_handles_oserror(mock_config: Any, mock_messagebox: Any) -> None:
51+
"""Test that save_settings handles OSError gracefully."""
52+
# Access the private class through the module
53+
orbital_selection_screen_class = plotter_module._OrbitalSelectionScreen # noqa: SLF001
54+
55+
# Set up mock to raise OSError
56+
mock_config.save_current_config = MagicMock(side_effect=OSError('Permission denied'))
57+
58+
# Create a minimal instance
59+
selection_screen = orbital_selection_screen_class.__new__(orbital_selection_screen_class)
60+
61+
# Call the method
62+
selection_screen.save_settings()
63+
64+
# Verify that error message was shown
65+
mock_messagebox.showerror.assert_called_once()
66+
args = mock_messagebox.showerror.call_args[0]
67+
assert 'Save Error' in args[0]
68+
assert 'Failed to save configuration' in args[1]
69+
assert 'Permission denied' in args[1]
70+
71+
72+
@patch('moldenViz.plotter.messagebox')
73+
@patch('moldenViz.plotter.config')
74+
def test_save_settings_handles_valueerror(mock_config: Any, mock_messagebox: Any) -> None:
75+
"""Test that save_settings handles ValueError gracefully."""
76+
# Access the private class through the module
77+
orbital_selection_screen_class = plotter_module._OrbitalSelectionScreen # noqa: SLF001
78+
79+
# Set up mock to raise ValueError
80+
mock_config.save_current_config = MagicMock(side_effect=ValueError('Invalid config'))
81+
82+
# Create a minimal instance
83+
selection_screen = orbital_selection_screen_class.__new__(orbital_selection_screen_class)
84+
85+
# Call the method
86+
selection_screen.save_settings()
87+
88+
# Verify that error message was shown
89+
mock_messagebox.showerror.assert_called_once()
90+
args = mock_messagebox.showerror.call_args[0]
91+
assert 'Save Error' in args[0]
92+
assert 'Failed to save configuration' in args[1]
93+
assert 'Invalid config' in args[1]

0 commit comments

Comments
 (0)