|
| 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