Skip to content

Commit 7d07c9d

Browse files
chrisdpurcellclaude
andcommitted
docs: add file merge and preferences feature design
Two approved designs for v0.4.0: - File Merge tab: QListWidget with drag/up-down reorder, Add Current + Add Files... dialog, user-configurable separator, output to editor. TextProcessingService.merge_documents() + ViewModel merge state/slots. - Preferences dialog: modal QDialog (.ui), QSettings for font, wrap, line numbers (stored/deferred), theme, default directory. Applied live via preferences_changed signal → MainWindow._apply_preferences(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 373beb7 commit 7d07c9d

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# TextTools — File Merge & Preferences Design
2+
3+
**Date**: 2026-02-22
4+
**Scope**: Two new features — File Merge tab and Preferences dialog
5+
**Goal**: Implement the two remaining "coming soon" features in TextTools v0.4.0
6+
7+
---
8+
9+
## 1. File Merge Feature
10+
11+
### 1.1 Summary
12+
13+
Replace the "Merge — coming soon" label in the Merge tab with a full multi-file merge UI.
14+
Users build a list of files, configure a separator, and click Merge to load the result into the
15+
right-panel editor for review/save via the normal Save/Save As flow.
16+
17+
### 1.2 Merge Tab UI Layout
18+
19+
Replace the `mergeComingSoonLabel` inside `mergeScrollContents` with this layout:
20+
21+
```
22+
┌──────────────────────────────────────────────┐
23+
│ Files to Merge: │
24+
│ ┌────────────────────────────────────┐ [↑] │
25+
│ │ file1.txt │ [↓] │
26+
│ │ notes.txt │ │
27+
│ │ log.txt │ [-] │
28+
│ └────────────────────────────────────┘ │
29+
│ │
30+
│ [Add Current File] [Add Files...] │
31+
│ │
32+
│ Separator between files: │
33+
│ [__\n__________________________] │
34+
│ │
35+
│ [ Merge Files ] │
36+
└──────────────────────────────────────────────┘
37+
```
38+
39+
**Widget objectNames** (to be added to DESIGN.md Appendix A):
40+
41+
| Widget | Type | objectName |
42+
|--------|------|------------|
43+
| File list | QListWidget | `mergeFileList` |
44+
| Move up | QPushButton | `mergeMoveUpButton` |
45+
| Move down | QPushButton | `mergeMoveDownButton` |
46+
| Remove selected | QPushButton | `mergeRemoveButton` |
47+
| Add current file | QPushButton | `mergeAddCurrentButton` |
48+
| Add via dialog | QPushButton | `mergeAddFilesButton` |
49+
| Separator input | QLineEdit | `mergeSeparatorEdit` |
50+
| Merge action | QPushButton | `mergeButton` |
51+
52+
**`mergeFileList` config**: `DragDropMode.InternalMove`, `DefaultDropAction.MoveAction`.
53+
Rows display filename only; full path stored in `Qt.ItemDataRole.UserRole`.
54+
55+
### 1.3 MVVM Architecture
56+
57+
#### Service layer
58+
59+
Add one method to `TextProcessingService` (no new files):
60+
61+
```python
62+
def merge_documents(self, docs: list[TextDocument], separator: str) -> str:
63+
return separator.join(doc.content for doc in docs)
64+
```
65+
66+
#### ViewModel (`MainViewModel`)
67+
68+
New internal state:
69+
- `_merge_filepaths: list[str]` (ordered list of absolute paths)
70+
- `_merge_separator: str = "\n"` (default: single blank line between files)
71+
72+
New signal:
73+
- `merge_list_changed = Signal(list)` — carries `list[str]` of display names (filename only)
74+
75+
New slots:
76+
77+
| Slot | Signature | Behaviour |
78+
|------|-----------|-----------|
79+
| `add_current_to_merge` | `()` | Appends `_current_document.filepath`; error if no file loaded |
80+
| `add_files_to_merge` | `(filepaths: list[str])` | Batch append; silently drops duplicates |
81+
| `remove_from_merge` | `(index: int)` | Removes by index |
82+
| `move_merge_item` | `(from_idx: int, to_idx: int)` | Reorders list |
83+
| `set_merge_separator` | `(sep: str)` | Updates separator string |
84+
| `execute_merge` | `()` | Reads all files via FileService, merges via TextProcessingService, emits `document_loaded` |
85+
86+
#### View (`MainWindow`)
87+
88+
- `_setup_merge_tab()` — wire all merge widgets to ViewModel slots
89+
- `_on_merge_list_changed(names: list[str])` — refresh `mergeFileList`
90+
- `_on_merge_add_files_clicked()` — open `QFileDialog.getOpenFileNames()``viewmodel.add_files_to_merge`
91+
- Connect `mergeFileList.model().rowsMoved``viewmodel.move_merge_item` for drag-drop sync
92+
93+
### 1.4 Data Flow
94+
95+
**Happy path:**
96+
```
97+
1. User clicks file in tree → viewmodel.load_file() → document_loaded
98+
2. User switches to Merge tab
99+
3. User clicks "Add Current File" → viewmodel.add_current_to_merge()
100+
→ merge_list_changed → view refreshes mergeFileList
101+
4. Repeat steps 1–3 for each file
102+
5. User edits separator field → viewmodel.set_merge_separator(text)
103+
6. User clicks "Merge Files" → viewmodel.execute_merge()
104+
→ FileService.open_file() for each path
105+
→ TextProcessingService.merge_documents(docs, separator)
106+
→ document_loaded → right-panel editor populated
107+
→ status_changed: "Merged N files"
108+
```
109+
110+
**Error handling:**
111+
112+
| Scenario | Response |
113+
|----------|----------|
114+
| "Add Current" with no file loaded | `error_occurred.emit("No file loaded — open a file first")` |
115+
| Merge list empty on "Merge Files" | `error_occurred.emit("No files in merge list")` |
116+
| File deleted between add and merge | `error_occurred.emit(f"Cannot read {name}: {e}")` — merge aborted |
117+
| Duplicate file added | Silently ignored |
118+
119+
### 1.5 Tests
120+
121+
**Unit — additions to existing test files:**
122+
123+
`tests/unit/test_text_processing_service.py`:
124+
- `test_merge_two_docs`
125+
- `test_merge_custom_separator`
126+
- `test_merge_single_doc`
127+
- `test_merge_empty_list`
128+
129+
`tests/unit/test_main_viewmodel.py`:
130+
- `test_add_current_to_merge`
131+
- `test_add_current_no_file_emits_error`
132+
- `test_add_files_to_merge`
133+
- `test_remove_from_merge`
134+
- `test_move_merge_item`
135+
- `test_execute_merge_emits_document_loaded`
136+
- `test_execute_merge_empty_list_emits_error`
137+
- `test_duplicate_ignored`
138+
139+
**Integration — new class in `tests/integration/test_main_window.py`:**
140+
141+
```python
142+
class TestMergeWorkflow:
143+
def test_add_current_and_merge(self, window, tmp_path, qtbot): ...
144+
def test_merge_tab_list_refreshes_on_add(self, ...): ...
145+
def test_merge_empty_list_shows_error(self, ...): ...
146+
```
147+
148+
---
149+
150+
## 2. Preferences Dialog
151+
152+
### 2.1 Summary
153+
154+
Replace the `Edit → Preferences` "coming soon" stub with a modal `QDialog` that reads/writes
155+
QSettings. Changes are applied live via a `preferences_changed` signal connected to
156+
`MainWindow._apply_preferences()`.
157+
158+
### 2.2 UI Layout
159+
160+
New file: `src/views/ui/preferences_dialog.ui`
161+
162+
```
163+
┌─────────────────────────────────────────┐
164+
│ Preferences │
165+
├─────────────────────────────────────────┤
166+
│ Editor │
167+
│ ───────────────────────────────────── │
168+
│ Font: [Monospace ▼] [12 ▲▼] │
169+
│ Word wrap: [ ] Wrap long lines │
170+
│ Line numbers: [x] Show line numbers │
171+
│ │
172+
│ Appearance │
173+
│ ───────────────────────────────────── │
174+
│ Theme: ( ) Light (•) Dark │
175+
│ │
176+
│ Files │
177+
│ ───────────────────────────────────── │
178+
│ Default directory: [/home/user ] [..] │
179+
│ │
180+
│ ────────────────────────────────────── │
181+
│ [Cancel] [Apply] [OK] │
182+
└─────────────────────────────────────────┘
183+
```
184+
185+
**Widget objectNames:**
186+
187+
| Widget | Type | objectName |
188+
|--------|------|------------|
189+
| Font family | QFontComboBox | `fontFamilyComboBox` |
190+
| Font size | QSpinBox | `fontSizeSpinBox` |
191+
| Word wrap | QCheckBox | `wordWrapCheckBox` |
192+
| Line numbers | QCheckBox | `lineNumbersCheckBox` |
193+
| Theme light | QRadioButton | `themeLightRadio` |
194+
| Theme dark | QRadioButton | `themeDarkRadio` |
195+
| Default dir | QLineEdit | `defaultDirectoryEdit` |
196+
| Browse | QPushButton | `browseDirectoryButton` |
197+
| OK | QPushButton | `okButton` |
198+
| Apply | QPushButton | `applyButton` |
199+
| Cancel | QPushButton | `cancelButton` |
200+
201+
### 2.3 Architecture
202+
203+
**`PreferencesDialog`** — new file `src/views/preferences_dialog.py`:
204+
- Loads `preferences_dialog.ui` via `QUiLoader`
205+
- `__init__`: reads QSettings, populates widgets
206+
- `_on_apply_clicked()`: writes QSettings, emits `preferences_changed`
207+
- `_on_ok_clicked()`: apply then `accept()`
208+
- `preferences_changed = Signal()` — MainWindow connects to `_apply_preferences()`
209+
210+
**Deliberate MVVM exception**: Preferences dialogs have no domain logic and no service calls.
211+
`PreferencesDialog` uses QSettings directly, matching the precedent in
212+
`MainWindow._save_settings()` / `_load_settings()`.
213+
214+
**QSettings keys:**
215+
```
216+
TextTools/editor/font_family str, default "Monospace"
217+
TextTools/editor/font_size int, default 12
218+
TextTools/editor/word_wrap bool, default False
219+
TextTools/editor/line_numbers bool, default True
220+
TextTools/appearance/theme str, "light" | "dark", default "light"
221+
TextTools/files/default_directory str, default QDir.homePath()
222+
```
223+
224+
**`MainWindow` changes:**
225+
- `actionPreferences``PreferencesDialog(parent=self).exec()`
226+
- `PreferencesDialog.preferences_changed``self._apply_preferences()`
227+
- `_apply_preferences()` called once on startup (after `_load_settings()`)
228+
229+
**`MainWindow._apply_preferences()`** reads QSettings and applies live:
230+
- Font → `plainTextEdit.setFont()`
231+
- Word wrap → `plainTextEdit.setLineWrapMode()`
232+
- Theme → `QApplication.setPalette()` (dark via `QApplication.style().standardPalette()` override)
233+
- Default dir → `_fs_model.setRootPath()` + `_file_tree_view.setRootIndex()`
234+
- Line numbers → stored for future `LineNumberArea` widget (not yet implemented — see Known Limits)
235+
236+
### 2.4 Known Limits
237+
238+
**Line number area** (deferred): Showing line numbers in `QPlainTextEdit` requires a custom
239+
`LineNumberEditor` subclass that paints a `LineNumberArea` widget into the editor's left viewport
240+
margin. This is the Qt Code Editor pattern. The Preferences checkbox stores the setting, but
241+
`_apply_preferences()` will log a warning that line numbers are not yet rendered. The
242+
`LineNumberEditor` implementation is a separate follow-on task.
243+
244+
### 2.5 Tests
245+
246+
**Unit — new file `tests/unit/test_preferences_dialog.py`:**
247+
- `test_dialog_reads_qsettings_on_open`
248+
- `test_apply_writes_qsettings`
249+
- `test_cancel_does_not_write_qsettings`
250+
- `test_preferences_changed_signal_emitted_on_apply`
251+
252+
**Integration — new class in `tests/integration/test_main_window.py`:**
253+
- `test_open_preferences_dialog`
254+
- `test_font_size_applied_to_editor`
255+
- `test_word_wrap_applied_to_editor`
256+
- `test_theme_applied_to_palette`
257+
- `test_preferences_persisted_across_sessions`
258+
259+
---
260+
261+
## 3. Implementation Order
262+
263+
```
264+
Phase 1 — File Merge
265+
1a. Add TextProcessingService.merge_documents() + unit tests
266+
1b. Add MainViewModel merge state + slots + signals + unit tests
267+
1c. Update main_window.ui — replace coming-soon label with merge widgets
268+
1d. Wire merge tab in MainWindow + integration tests
269+
270+
Phase 2 — Preferences Dialog
271+
2a. Create preferences_dialog.ui in Qt Designer
272+
2b. Implement PreferencesDialog class + unit tests
273+
2c. Wire actionPreferences in MainWindow + _apply_preferences() + integration tests
274+
2d. Call _apply_preferences() on startup
275+
```
276+
277+
Each phase completes with all tests passing before the next begins.

0 commit comments

Comments
 (0)