351 lines
11 KiB
Markdown
351 lines
11 KiB
Markdown
# ImGui Context Manager Suite Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Create a context manager suite that pairs `begin`/`end` ImGui calls using Python's `with` statement to reduce scope pairing errors and improve AI legibility.
|
|
|
|
**Architecture:** Base `ImGuiScope` class with factory functions returning scope objects. Each scope handles begin on `__enter__` and end on `__exit__`.
|
|
|
|
**Tech Stack:** Python 3.11+, imgui-bundle (Dear PyGui)
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| Action | Path |
|
|
|--------|------|
|
|
| Create | `src/imgui_scopes.py` |
|
|
| Create | `tests/test_imgui_scopes.py` |
|
|
|
|
---
|
|
|
|
## Task 1: Create `src/imgui_scopes.py`
|
|
|
|
**Files:**
|
|
- Create: `src/imgui_scopes.py`
|
|
|
|
- [ ] **Step 1: Write the module**
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
import imgui_bundle
|
|
|
|
|
|
class ImGuiScope:
|
|
def __init__(self, begin_fn, end_fn, *args, **kwargs):
|
|
self._begin_fn = begin_fn
|
|
self._end_fn = end_fn
|
|
self._args = args
|
|
self._kwargs = kwargs
|
|
self._opened: bool | tuple = False
|
|
self._entered = False
|
|
|
|
def __enter__(self):
|
|
result = self._begin_fn(*self._args, **self._kwargs)
|
|
if isinstance(result, tuple):
|
|
self._opened = result[0]
|
|
else:
|
|
self._opened = result
|
|
self._entered = bool(self._opened)
|
|
return self._opened
|
|
|
|
def __exit__(self, *args):
|
|
if self._entered:
|
|
self._end_fn()
|
|
return False
|
|
|
|
|
|
def imgui_window(name: str, visible: bool = True, flags: int = 0):
|
|
return ImGuiScope(imgui_bundle.imgui.begin, imgui_bundle.imgui.end, name, visible, flags)
|
|
|
|
|
|
def imgui_table(name: str, columns: int, flags: int = 0):
|
|
return ImGuiScope(imgui_bundle.imgui.begin_table, imgui_bundle.imgui.end_table, name, columns, flags)
|
|
|
|
|
|
def imgui_menu_bar():
|
|
return ImGuiScope(imgui_bundle.imgui.begin_menu_bar, imgui_bundle.imgui.end_menu_bar)
|
|
|
|
|
|
def imgui_menu(label: str):
|
|
return ImGuiScope(imgui_bundle.imgui.begin_menu, imgui_bundle.imgui.end_menu, label)
|
|
|
|
|
|
def imgui_child(id_str: str, width: float = 0, height: float = 0, flags: int = 0):
|
|
return ImGuiScope(imgui_bundle.imgui.begin_child, imgui_bundle.imgui.end_child, id_str, width, height, flags)
|
|
|
|
|
|
def imgui_group():
|
|
return ImGuiScope(imgui_bundle.imgui.begin_group, imgui_bundle.imgui.end_group)
|
|
|
|
|
|
def imgui_popup(id_str: str):
|
|
return ImGuiScope(imgui_bundle.imgui.begin_popup, imgui_bundle.imgui.end_popup, id_str)
|
|
|
|
|
|
def imgui_tooltip():
|
|
return ImGuiScope(imgui_bundle.imgui.begin_tooltip, imgui_bundle.imgui.end_tooltip)
|
|
|
|
|
|
def imgui_clipper(count: int):
|
|
return ImGuiScope(
|
|
lambda n: imgui_bundle.imgui.listing_builder.begin_clipper(n, -1),
|
|
imgui_bundle.imgui.listing_builder.end_clipper,
|
|
count
|
|
)
|
|
|
|
|
|
def node_editor_scope(name: str):
|
|
ed = imgui_bundle.imgui_node_editor
|
|
return ImGuiScope(ed.begin, ed.end, name)
|
|
```
|
|
|
|
- [ ] **Step 2: Verify syntax**
|
|
|
|
Run: `uv run python -c "import ast; ast.parse(open('src/imgui_scopes.py').read())"`
|
|
Expected: No output (parse successful)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/imgui_scopes.py
|
|
git commit -m "feat(gui): Add ImGuiScope base class and scope helpers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create `tests/test_imgui_scopes.py`
|
|
|
|
**Files:**
|
|
- Create: `tests/test_imgui_scopes.py`
|
|
- Depends on: Task 1
|
|
|
|
- [ ] **Step 1: Write tests**
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
from unittest.mock import patch, MagicMock
|
|
import pytest
|
|
|
|
|
|
class TestImGuiScope:
|
|
def test_enter_calls_begin_with_args(self):
|
|
from src.imgui_scopes import ImGuiScope
|
|
mock_begin = MagicMock(return_value=True)
|
|
mock_end = MagicMock()
|
|
scope = ImGuiScope(mock_begin, mock_end, "arg1", kwarg="val")
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once_with("arg1", kwarg="val")
|
|
|
|
def test_exit_calls_end_when_entered(self):
|
|
from src.imgui_scopes import ImGuiScope
|
|
mock_begin = MagicMock(return_value=True)
|
|
mock_end = MagicMock()
|
|
scope = ImGuiScope(mock_begin, mock_end)
|
|
with scope:
|
|
pass
|
|
mock_end.assert_called_once()
|
|
|
|
def test_exit_does_not_call_end_when_not_entered(self):
|
|
from src.imgui_scopes import ImGuiScope
|
|
mock_begin = MagicMock(return_value=False)
|
|
mock_end = MagicMock()
|
|
scope = ImGuiScope(mock_begin, mock_end)
|
|
with scope:
|
|
pass
|
|
mock_end.assert_not_called()
|
|
|
|
def test_enter_returns_opened_value(self):
|
|
from src.imgui_scopes import ImGuiScope
|
|
mock_begin = MagicMock(return_value=True)
|
|
mock_end = MagicMock()
|
|
scope = ImGuiScope(mock_begin, mock_end)
|
|
result = scope.__enter__()
|
|
assert result is True
|
|
|
|
def test_enter_handles_tuple_return(self):
|
|
from src.imgui_scopes import ImGuiScope
|
|
mock_begin = MagicMock(return_value=(True, "extra"))
|
|
mock_end = MagicMock()
|
|
scope = ImGuiScope(mock_begin, mock_end)
|
|
with scope:
|
|
pass
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiWindow:
|
|
def test_imgui_window_returns_scope(self):
|
|
from src.imgui_scopes import imgui_window
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end") as mock_end:
|
|
scope = imgui_window("Test")
|
|
assert hasattr(scope, "__enter__")
|
|
assert hasattr(scope, "__exit__")
|
|
|
|
|
|
class TestImguiTable:
|
|
def test_imgui_table_returns_scope(self):
|
|
from src.imgui_scopes import imgui_table
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_table", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_table") as mock_end:
|
|
scope = imgui_table("TestTable", 3)
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiMenuBar:
|
|
def test_imgui_menu_bar_no_args(self):
|
|
from src.imgui_scopes import imgui_menu_bar
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_menu_bar", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_menu_bar") as mock_end:
|
|
scope = imgui_menu_bar()
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once_with()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiMenu:
|
|
def test_imgui_menu_returns_scope(self):
|
|
from src.imgui_scopes import imgui_menu
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_menu", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_menu") as mock_end:
|
|
scope = imgui_menu("File")
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiChild:
|
|
def test_imgui_child_returns_scope(self):
|
|
from src.imgui_scopes import imgui_child
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_child", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_child") as mock_end:
|
|
scope = imgui_child("child_id", 100, 200)
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiGroup:
|
|
def test_imgui_group_no_args(self):
|
|
from src.imgui_scopes import imgui_group
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_group", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_group") as mock_end:
|
|
scope = imgui_group()
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiPopup:
|
|
def test_imgui_popup_returns_scope(self):
|
|
from src.imgui_scopes import imgui_popup
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_popup", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_popup") as mock_end:
|
|
scope = imgui_popup("popup_id")
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestImguiTooltip:
|
|
def test_imgui_tooltip_no_args(self):
|
|
from src.imgui_scopes import imgui_tooltip
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.begin_tooltip", return_value=True) as mock_begin:
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui.end_tooltip") as mock_end:
|
|
scope = imgui_tooltip()
|
|
with scope:
|
|
pass
|
|
mock_begin.assert_called_once()
|
|
mock_end.assert_called_once()
|
|
|
|
|
|
class TestNodeEditorScope:
|
|
def test_node_editor_scope_returns_scope(self):
|
|
from src.imgui_scopes import node_editor_scope
|
|
mock_ed = MagicMock()
|
|
mock_ed.begin = MagicMock(return_value=True)
|
|
mock_ed.end = MagicMock()
|
|
with patch("src.imgui_scopes.imgui_bundle.imgui_node_editor", mock_ed):
|
|
scope = node_editor_scope("TestNode")
|
|
with scope:
|
|
pass
|
|
mock_ed.begin.assert_called_once()
|
|
mock_ed.end.assert_called_once()
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
Run: `uv run pytest tests/test_imgui_scopes.py -v`
|
|
Expected: All tests PASS
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/test_imgui_scopes.py
|
|
git commit -m "test(gui): Add tests for ImGuiScope context managers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Integration Verification
|
|
|
|
**Files:**
|
|
- Modify: `src/gui_2.py` (sample migration of 2-3 windows)
|
|
- Depends on: Task 1
|
|
|
|
- [ ] **Step 1: Add import to gui_2.py**
|
|
|
|
Add near line 1 (after existing imports):
|
|
```python
|
|
from src.imgui_scopes import imgui_window, imgui_table, imgui_menu_bar, imgui_menu, imgui_child, imgui_group, imgui_popup, imgui_tooltip, imgui_clipper, node_editor_scope
|
|
```
|
|
|
|
- [ ] **Step 2: Migrate one window as proof-of-concept**
|
|
|
|
Pick one window in `_gui_func` (e.g., lines 1061-1056 for "AI Settings"):
|
|
|
|
Before:
|
|
```python
|
|
exp, opened = imgui.begin("AI Settings", self.show_windows["AI Settings"])
|
|
if exp:
|
|
# ... content ...
|
|
imgui.end()
|
|
```
|
|
|
|
After:
|
|
```python
|
|
if imgui_window("AI Settings", self.show_windows["AI Settings"]):
|
|
# ... content ...
|
|
# end() called automatically
|
|
```
|
|
|
|
- [ ] **Step 3: Run smoke test**
|
|
|
|
Run: `uv run pytest tests/test_gui_startup_smoke.py -v`
|
|
Expected: PASS (no regression from import)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/gui_2.py
|
|
git commit -m "refactor(gui): Migrate AI Settings window to imgui_window scope"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
- [ ] All scope helpers implemented with correct signatures
|
|
- [ ] Tests mock all begin/end pairs
|
|
- [ ] Integration test confirms no regression in gui_2.py
|
|
- [ ] No placeholders (TBD, TODO) in any step
|
|
- [ ] Each task commits independently |