diff --git a/src/app_controller.py b/src/app_controller.py index 2e50d73..eeaff59 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -226,6 +226,7 @@ class AppController: self._pending_actions: Dict[str, ConfirmDialog] = {} self._pending_ask_dialog: bool = False self.mcp_config: models.MCPConfiguration = models.MCPConfiguration() + self.view_presets: list[models.NamedViewPreset] = [] self.rag_config: Optional[models.RAGConfig] = None self.rag_engine: Optional[rag_engine.RAGEngine] = None self.rag_status: str = 'idle' @@ -2449,6 +2450,12 @@ class AppController: self.tool_presets = self.tool_preset_manager.load_all_presets() self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() + raw_presets = proj.get("view_presets", []) + if isinstance(raw_presets, dict): + self.view_presets = [models.NamedViewPreset.from_dict({"name": name, **data}) for name, data in raw_presets.items()] + else: + self.view_presets = [models.NamedViewPreset.from_dict(p) for p in raw_presets if isinstance(p, dict)] + if self.rag_config and self.rag_config.enabled: self._rebuild_rag_index() @@ -2561,6 +2568,41 @@ class AppController: self.persona_manager.delete_persona(name, scope) self.personas = self.persona_manager.load_all() + def _cb_save_view_preset(self, name: str, f_item: models.FileItem) -> None: + """ + [C: src/gui_2.py:App._render_files_panel] + """ + preset = models.NamedViewPreset( + name=name, + view_mode=f_item.view_mode, + ast_mask=copy.deepcopy(f_item.ast_mask) if hasattr(f_item, "ast_mask") else {}, + custom_slices=copy.deepcopy(f_item.custom_slices) if hasattr(f_item, "custom_slices") else [] + ) + for i, vp in enumerate(self.view_presets): + if vp.name == name: + self.view_presets[i] = preset + break + else: + self.view_presets.append(preset) + self._flush_to_project() + + def _cb_apply_view_preset(self, name: str, f_item: models.FileItem) -> None: + """ + [C: src/gui_2.py:App._render_files_panel] + """ + preset = next((vp for vp in self.view_presets if vp.name == name), None) + if preset: + f_item.view_mode = preset.view_mode + f_item.ast_mask = copy.deepcopy(preset.ast_mask) + f_item.custom_slices = copy.deepcopy(preset.custom_slices) + + def _cb_delete_view_preset(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_files_panel] + """ + self.view_presets = [vp for vp in self.view_presets if vp.name != name] + self._flush_to_project() + def _cb_load_track(self, track_id: str) -> None: """ @@ -3011,6 +3053,7 @@ class AppController: disc_sec["roles"] = self.disc_roles disc_sec["active"] = self.active_discussion disc_sec["auto_add"] = self.ui_auto_add_history + proj["view_presets"] = [vp.to_dict() for vp in self.view_presets] # Save MMA State mma_sec = proj.setdefault("mma", {}) mma_sec["epic"] = self.ui_epic_input diff --git a/src/gui_2.py b/src/gui_2.py index 2422f0a..09209ee 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -238,6 +238,7 @@ class App: self.shader_uniforms = {'crt': 1.0, 'scanline': 0.5, 'bloom': 0.8} self.shader_uniforms = {'crt': 1.0, 'scanline': 0.5, 'bloom': 0.8} self.ui_new_context_preset_name = "" + self.ui_new_vp_name = "" self._focus_md_cache: dict[str, str] = {} self.ui_inspecting_ast_file = None self._show_ast_inspector = False @@ -3048,6 +3049,34 @@ class App: changed_vm, new_idx = imgui.combo(f"##vm{i}", current_idx, view_modes) if changed_vm: f_item.view_mode = view_modes[new_idx] + + imgui.same_line() + if imgui.button(f"[Save]##vpsave{i}"): + imgui.open_popup(f"save_vp_popup{i}") + + if imgui.begin_popup(f"save_vp_popup{i}"): + imgui.text("Preset Name:") + changed_pname, self.ui_new_vp_name = imgui.input_text(f"##pname{i}", self.ui_new_vp_name) + if imgui.button("OK"): + if self.ui_new_vp_name.strip(): + self.controller._cb_save_view_preset(self.ui_new_vp_name.strip(), f_item) + self.ui_new_vp_name = "" + imgui.close_current_popup() + imgui.end_popup() + + imgui.same_line() + if imgui.button(f"[Load]##vpload{i}"): + imgui.open_popup(f"load_vp_popup{i}") + + if imgui.begin_popup(f"load_vp_popup{i}"): + vp_names = sorted([vp.name for vp in self.controller.view_presets]) + if not vp_names: + imgui.text("No presets saved.") + for vp_name in vp_names: + if imgui.selectable(vp_name): + self.controller._cb_apply_view_preset(vp_name, f_item) + imgui.close_current_popup() + imgui.end_popup() if hasattr(f_item, "custom_slices") and f_item.custom_slices: imgui.same_line() imgui.text_colored(imgui.ImVec4(1.0, 0.5, 0.0, 1.0), "[Slices Active]") diff --git a/src/models.py b/src/models.py index 33cbb47..1ca1ef6 100644 --- a/src/models.py +++ b/src/models.py @@ -939,6 +939,30 @@ class FileViewPreset: view_mode=data.get("view_mode", "summary") ) +@dataclass +class NamedViewPreset: + name: str + view_mode: str + ast_mask: dict = field(default_factory=dict) + custom_slices: list = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "view_mode": self.view_mode, + "ast_mask": self.ast_mask, + "custom_slices": self.custom_slices + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "NamedViewPreset": + return cls( + name=data.get("name", ""), + view_mode=data.get("view_mode", "summary"), + ast_mask=data.get("ast_mask", {}), + custom_slices=data.get("custom_slices", []) + ) + @dataclass class ContextPreset: name: str diff --git a/tests/test_view_presets.py b/tests/test_view_presets.py new file mode 100644 index 0000000..b1ee5c3 --- /dev/null +++ b/tests/test_view_presets.py @@ -0,0 +1,117 @@ +import os +import pytest +import copy +from src import models +from src.app_controller import AppController + +@pytest.fixture +def controller(tmp_path): + # Create a mock project file + proj_path = tmp_path / "test_project.toml" + proj_path.write_text("[project]\nname = 'test'\n") + + ctrl = AppController() + ctrl.active_project_path = str(proj_path) + ctrl.project = {"project": {"name": "test"}} + ctrl.view_presets = [] + + # Initialize missing attributes needed for flush/refresh + ctrl.ui_output_dir = "./md_gen" + ctrl.ui_files_base_dir = "." + ctrl.ui_shots_base_dir = "." + ctrl.ui_project_git_dir = "" + ctrl.ui_project_conductor_dir = "conductor" + ctrl.ui_project_system_prompt = "" + ctrl.ui_project_preset_name = None + ctrl.ui_gemini_cli_path = "gemini" + ctrl.ui_word_wrap = True + ctrl.ui_auto_add_history = False + ctrl.ui_auto_scroll_comms = True + ctrl.ui_auto_scroll_tool_calls = True + ctrl.ui_agent_tools = {} + ctrl.ui_epic_input = "" + ctrl.ui_active_context_preset = "" + ctrl.preset_manager = type('Mock', (), {'load_all': lambda self: {}, 'project_root': None})() + ctrl.tool_preset_manager = type('Mock', (), {'load_all_presets': lambda self: {}, 'load_all_bias_profiles': lambda self: {}, 'project_root': None})() + + return ctrl + +def test_save_view_preset(controller): + f_item = models.FileItem(path="test.py", view_mode="skeleton") + f_item.ast_mask = {"test::func": "sig"} + f_item.custom_slices = [{"start_line": 1, "end_line": 10}] + + controller._cb_save_view_preset("my_preset", f_item) + + assert any(vp.name == "my_preset" for vp in controller.view_presets) + preset = next(vp for vp in controller.view_presets if vp.name == "my_preset") + assert preset.view_mode == "skeleton" + assert preset.ast_mask == {"test::func": "sig"} + assert preset.custom_slices == [{"start_line": 1, "end_line": 10}] + + # Verify persistence + controller._flush_to_project() + assert "view_presets" in controller.project + assert isinstance(controller.project["view_presets"], list) + assert any(vp["name"] == "my_preset" for vp in controller.project["view_presets"]) + +def test_apply_view_preset(controller): + # Setup a preset + preset = models.NamedViewPreset( + name="my_preset", + view_mode="masked", + ast_mask={"main::run": "def"}, + custom_slices=[{"start_line": 5, "end_line": 15}] + ) + controller.view_presets.append(preset) + + # Create a file item to apply to + f_item = models.FileItem(path="main.py", view_mode="summary") + + controller._cb_apply_view_preset("my_preset", f_item) + + assert f_item.view_mode == "masked" + assert f_item.ast_mask == {"main::run": "def"} + assert f_item.custom_slices == [{"start_line": 5, "end_line": 15}] + +def test_delete_view_preset(controller): + preset = models.NamedViewPreset(name="to_del", view_mode="full") + controller.view_presets.append(preset) + + controller._cb_delete_view_preset("to_del") + + assert not any(vp.name == "to_del" for vp in controller.view_presets) + +def test_load_presets_from_project_list(controller): + controller.project["view_presets"] = [ + { + "name": "stored_preset", + "view_mode": "outline", + "ast_mask": {"a": "b"}, + "custom_slices": [] + } + ] + + controller._refresh_from_project() + + assert any(vp.name == "stored_preset" for vp in controller.view_presets) + preset = next(vp for vp in controller.view_presets if vp.name == "stored_preset") + assert preset.view_mode == "outline" + assert preset.ast_mask == {"a": "b"} + +def test_load_presets_from_project_legacy_dict(controller): + # Test backward compatibility + controller.project["view_presets"] = { + "legacy_preset": { + "view_mode": "full", + "ast_mask": {"c": "d"}, + "custom_slices": [] + } + } + + controller._refresh_from_project() + + assert any(vp.name == "legacy_preset" for vp in controller.view_presets) + preset = next(vp for vp in controller.view_presets if vp.name == "legacy_preset") + assert preset.view_mode == "full" + assert preset.ast_mask == {"c": "d"}