Skip to content

Commit 69be1d8

Browse files
committed
Merge branch 'testing'
2 parents d08a611 + e99fb2e commit 69be1d8

File tree

9 files changed

+338
-118
lines changed

9 files changed

+338
-118
lines changed

src/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from src.services.file_service import FileService
1313
from src.services.text_processing_service import TextProcessingService
14+
from src.utils.constants import APP_NAME, APP_VERSION
1415
from src.viewmodels.main_viewmodel import MainViewModel
1516
from src.views.main_window import MainWindow
1617

@@ -35,8 +36,9 @@ def main() -> None:
3536
"""Application entry point."""
3637
logger.info("Starting TextTools")
3738
app = QApplication(sys.argv)
38-
app.setApplicationName("TextTools")
39-
app.setOrganizationName("TextTools")
39+
app.setApplicationName(APP_NAME)
40+
app.setApplicationVersion(APP_VERSION)
41+
app.setOrganizationName(APP_NAME)
4042

4143
window = create_application()
4244
window.show()

src/utils/constants.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
"""Utility functions and constants for the application."""
1+
"""Utility constants for TextTools."""
22

3-
# Application constants
43
APP_NAME = "TextTools"
5-
APP_VERSION = "1.0.0"
4+
APP_VERSION = "0.2.0"
65

7-
# Configuration
8-
DEFAULT_WINDOW_WIDTH = 800
9-
DEFAULT_WINDOW_HEIGHT = 600
6+
DEFAULT_WINDOW_WIDTH = 894
7+
DEFAULT_WINDOW_HEIGHT = 830
8+
9+
# File extensions shown in the QFileSystemModel tree.
10+
# Covers common text, config, markup, and script formats.
11+
TEXT_FILE_EXTENSIONS = [
12+
"*.txt", "*.md", "*.rst", "*.csv", "*.log",
13+
"*.json", "*.yaml", "*.yml", "*.toml", "*.xml", "*.html", "*.htm",
14+
"*.css", "*.js", "*.ts", "*.py", "*.sh", "*.bash",
15+
"*.conf", "*.cfg", "*.ini", "*.env",
16+
]

src/viewmodels/main_viewmodel.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,21 @@ class MainViewModel(QObject):
3737
3838
Signals:
3939
document_loaded: Emitted with decoded file content ready for the editor.
40+
Only load_file emits this — clears the modified flag in the View.
41+
content_updated: Emitted by apply_cleaning and replace_all.
42+
In-memory transformations only — the View must NOT clear the modified flag.
4043
encoding_detected: Emitted with encoding name on file open.
4144
file_saved: Emitted with filepath after a successful save.
4245
error_occurred: Emitted with error message on any failure.
4346
status_changed: Emitted with status bar text.
44-
find_requested: Emitted with search term; view performs the find.
45-
replace_requested: Emitted with (find, replace); view performs the replace.
4647
"""
4748

4849
document_loaded = Signal(str)
50+
content_updated = Signal(str) # emitted by apply_cleaning and replace_all
4951
encoding_detected = Signal(str)
5052
file_saved = Signal(str)
5153
error_occurred = Signal(str)
5254
status_changed = Signal(str)
53-
find_requested = Signal(str)
54-
replace_requested = Signal(str, str)
5555

5656
def __init__(
5757
self,
@@ -99,36 +99,59 @@ def save_file(self, filepath: str, content: str) -> None:
9999
self.error_occurred.emit(msg)
100100
self.status_changed.emit("Error saving file")
101101

102-
@Slot(object)
103-
def apply_cleaning(self, options: CleaningOptions) -> None:
104-
"""Apply text cleaning to current document content.
102+
def apply_cleaning(
103+
self, options: CleaningOptions, current_text: str | None = None
104+
) -> None:
105+
"""Apply text cleaning to the given text or current document content.
106+
107+
Args:
108+
options: Cleaning flags to apply.
109+
current_text: Live editor text from the View. When provided, takes
110+
precedence over _current_document.content so user edits typed
111+
after file-load are not discarded. When None, falls back to the
112+
last-loaded document content (backward-compatible default).
105113
106114
No-op when no document is loaded.
107115
"""
108116
if self._current_document is None:
109117
self.status_changed.emit("No document loaded")
110118
return
111-
cleaned = self._text_service.apply_options(
112-
self._current_document.content, options
119+
# Prefer live editor text over stale document state — avoids overwriting
120+
# user edits when a cleaning checkbox is toggled after in-editor typing.
121+
content = (
122+
current_text if current_text is not None else self._current_document.content
113123
)
124+
cleaned = self._text_service.apply_options(content, options)
114125
self._current_document = TextDocument(
115126
filepath=self._current_document.filepath,
116127
content=cleaned,
117128
encoding=self._current_document.encoding,
118129
modified=True,
119130
)
120-
self.document_loaded.emit(cleaned)
131+
self.content_updated.emit(cleaned)
121132
self.status_changed.emit("Text cleaned")
122133

123-
@Slot(str, str)
124-
def replace_all(self, find_term: str, replace_term: str) -> None:
125-
"""Replace all occurrences of find_term in current document content.
134+
def replace_all(
135+
self, find_term: str, replace_term: str, current_text: str | None = None
136+
) -> None:
137+
"""Replace all occurrences of find_term in the given text or current document.
138+
139+
Args:
140+
find_term: String to search for.
141+
replace_term: String to substitute.
142+
current_text: Live editor text from the View. When provided, takes
143+
precedence over _current_document.content so user edits typed
144+
after file-load are not discarded. When None, falls back to the
145+
last-loaded document content (backward-compatible default).
126146
127147
No-op when no document is loaded or find_term is empty.
128148
"""
129149
if self._current_document is None or not find_term:
130150
return
131-
content = self._current_document.content
151+
# Prefer live editor text over stale document state — mirrors apply_cleaning.
152+
content = (
153+
current_text if current_text is not None else self._current_document.content
154+
)
132155
count = content.count(find_term)
133156
new_content = content.replace(find_term, replace_term)
134157
self._current_document = TextDocument(
@@ -137,16 +160,6 @@ def replace_all(self, find_term: str, replace_term: str) -> None:
137160
encoding=self._current_document.encoding,
138161
modified=True,
139162
)
140-
self.document_loaded.emit(new_content)
163+
self.content_updated.emit(new_content)
141164
noun = "occurrence" if count == 1 else "occurrences"
142165
self.status_changed.emit(f"Replaced {count} {noun}")
143-
144-
@Slot(str)
145-
def request_find(self, term: str) -> None:
146-
"""Signal the view to find the next occurrence of term."""
147-
self.find_requested.emit(term)
148-
149-
@Slot(str, str)
150-
def request_replace(self, find_term: str, replace_term: str) -> None:
151-
"""Signal the view to replace the current selection."""
152-
self.replace_requested.emit(find_term, replace_term)

src/views/main_window.py

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
from typing import TypeVar, cast
1818

1919
from PySide6.QtCore import QDir, QFile, QModelIndex
20+
from PySide6.QtGui import QAction
2021
from PySide6.QtUiTools import QUiLoader
2122
from PySide6.QtWidgets import (
23+
QApplication,
2224
QCheckBox,
25+
QFileDialog,
2326
QFileSystemModel,
2427
QLabel,
2528
QLineEdit,
@@ -31,6 +34,7 @@
3134
)
3235

3336
from src.models.cleaning_options import CleaningOptions
37+
from src.utils.constants import APP_VERSION, TEXT_FILE_EXTENSIONS
3438
from src.viewmodels.main_viewmodel import MainViewModel
3539

3640
_W = TypeVar("_W")
@@ -66,6 +70,8 @@ class MainWindow:
6670

6771
def __init__(self, viewmodel: MainViewModel) -> None:
6872
self._viewmodel = viewmodel
73+
# Display-only filepath — ViewModel owns document state; this is for the title bar.
74+
self._filepath: str = ""
6975
self._load_ui()
7076
self._setup_file_tree()
7177
self._connect_signals()
@@ -137,11 +143,31 @@ def _load_ui(self) -> None:
137143
self.ui.findChild(QPushButton, "convertEncodingButton"),
138144
"convertEncodingButton",
139145
)
146+
self._action_quit = _require(
147+
self.ui.findChild(QAction, "actionQuit"), "actionQuit"
148+
)
149+
self._action_save = _require(
150+
self.ui.findChild(QAction, "actionSave"), "actionSave"
151+
)
152+
self._action_open = _require(
153+
self.ui.findChild(QAction, "actionOpen"), "actionOpen"
154+
)
155+
self._action_save_as = _require(
156+
self.ui.findChild(QAction, "actionSave_as"), "actionSave_as"
157+
)
158+
self._action_about = _require(
159+
self.ui.findChild(QAction, "actionAbout"), "actionAbout"
160+
)
161+
self._action_preferences = _require(
162+
self.ui.findChild(QAction, "actionPreferences"), "actionPreferences"
163+
)
140164

141165
def _setup_file_tree(self) -> None:
142166
"""Configure QFileSystemModel rooted at the user's home directory."""
143167
self._fs_model = QFileSystemModel(self.ui)
144168
self._fs_model.setRootPath(QDir.homePath())
169+
self._fs_model.setNameFilters(TEXT_FILE_EXTENSIONS)
170+
self._fs_model.setNameFilterDisables(False) # hide non-matches (not just grey them)
145171
self._file_tree_view.setModel(self._fs_model)
146172
self._file_tree_view.setRootIndex(self._fs_model.index(QDir.homePath()))
147173
# Hide size/type/date columns — name column only
@@ -172,13 +198,32 @@ def _connect_signals(self) -> None:
172198
lambda: self.ui.statusBar().showMessage("Encoding conversion — coming soon")
173199
)
174200

201+
# Menu actions
202+
self._action_quit.triggered.connect(QApplication.quit)
203+
self._action_save.triggered.connect(self._on_save_clicked)
204+
self._action_open.triggered.connect(self._on_action_open)
205+
self._action_save_as.triggered.connect(
206+
lambda: self.ui.statusBar().showMessage("Save As — coming soon")
207+
)
208+
self._action_about.triggered.connect(self._on_action_about)
209+
self._action_preferences.triggered.connect(
210+
lambda: self.ui.statusBar().showMessage("Preferences — coming soon")
211+
)
212+
175213
# ViewModel → View
176214
self._viewmodel.document_loaded.connect(self._on_document_loaded)
215+
self._viewmodel.content_updated.connect(self._on_content_updated)
177216
self._viewmodel.encoding_detected.connect(self._on_encoding_detected)
178217
self._viewmodel.file_saved.connect(self._on_file_saved)
179218
self._viewmodel.error_occurred.connect(self._on_error)
180219
self._viewmodel.status_changed.connect(self._on_status_changed)
181220

221+
# Title bar: modificationChanged fires only when isModified() flips, not on
222+
# every keystroke — avoids redundant title repaints and gives a clean signal.
223+
self._plain_text_edit.document().modificationChanged.connect(
224+
lambda _: self._update_title()
225+
)
226+
182227
# ---------------------------------------------------------- user actions
183228

184229
def _on_tree_item_clicked(self, index: QModelIndex) -> None:
@@ -201,13 +246,16 @@ def _on_save_clicked(self) -> None:
201246
self._viewmodel.save_file(filepath, self._plain_text_edit.toPlainText())
202247

203248
def _on_clean_requested(self) -> None:
204-
"""Build CleaningOptions from checkbox states; delegate to ViewModel."""
249+
"""Build CleaningOptions from checkbox states; delegate to ViewModel.
250+
251+
Passes live editor text so user edits made since file-load are not lost.
252+
"""
205253
options = CleaningOptions(
206254
trim_whitespace=self._trim_cb.isChecked(),
207255
clean_whitespace=self._clean_cb.isChecked(),
208256
remove_tabs=self._remove_tabs_cb.isChecked(),
209257
)
210-
self._viewmodel.apply_cleaning(options)
258+
self._viewmodel.apply_cleaning(options, self._plain_text_edit.toPlainText())
211259

212260
def _on_find_clicked(self) -> None:
213261
"""Find next occurrence of the search term in the editor."""
@@ -234,19 +282,85 @@ def _on_replace_clicked(self) -> None:
234282
self._on_find_clicked()
235283

236284
def _on_replace_all_clicked(self) -> None:
237-
"""Delegate replace-all to ViewModel (operates on stored content)."""
238-
self._viewmodel.replace_all(self._find_edit.text(), self._replace_edit.text())
285+
"""Delegate replace-all to ViewModel, passing live editor text.
286+
287+
Passes live editor text so user edits made since file-load are not lost.
288+
"""
289+
self._viewmodel.replace_all(
290+
self._find_edit.text(),
291+
self._replace_edit.text(),
292+
self._plain_text_edit.toPlainText(),
293+
)
294+
295+
def _on_action_open(self) -> None:
296+
"""Open a file dialog and load the selected file."""
297+
_glob = " ".join(TEXT_FILE_EXTENSIONS)
298+
path, _ = QFileDialog.getOpenFileName(
299+
self.ui,
300+
"Open File",
301+
QDir.homePath(),
302+
f"Text Files ({_glob});;All Files (*)",
303+
)
304+
if path:
305+
self._file_name_edit.setText(path)
306+
self._viewmodel.load_file(path)
307+
308+
def _on_action_about(self) -> None:
309+
"""Show an About dialog."""
310+
QMessageBox.about(
311+
self.ui,
312+
"About TextTools",
313+
f"TextTools v{APP_VERSION}\n\nText processing utility: encoding detection, "
314+
"whitespace cleaning, find/replace, and file management.\n\n"
315+
"Built with Python 3.14 and PySide6.",
316+
)
317+
318+
# ---------------------------------------------------------- title-bar helpers
319+
320+
def _set_editor_text(self, content: str) -> None:
321+
"""Replace editor content as a single undoable operation.
322+
323+
Uses QTextCursor.insertText instead of setPlainText to preserve the undo
324+
stack — setPlainText resets it entirely.
325+
"""
326+
cursor = self._plain_text_edit.textCursor()
327+
cursor.select(cursor.SelectionType.Document)
328+
cursor.insertText(content)
329+
330+
def _update_title(self) -> None:
331+
"""Reflect current filepath and modified state in the window title."""
332+
name = os.path.basename(self._filepath) if self._filepath else ""
333+
modified = self._plain_text_edit.document().isModified()
334+
suffix = " *" if modified else ""
335+
self.ui.setWindowTitle(f"TextTools — {name}{suffix}" if name else "TextTools")
239336

240337
# ------------------------------------------ ViewModel signal handlers
241338

242339
def _on_document_loaded(self, content: str) -> None:
243-
self._plain_text_edit.setPlainText(content)
340+
self._set_editor_text(content)
341+
self._plain_text_edit.document().setModified(False)
342+
# fileNameEdit is always populated before load_file is called (see
343+
# _on_tree_item_clicked and _on_action_open). Reading the widget here
344+
# keeps the View in its own layer — never access ViewModel private state.
345+
self._filepath = self._file_name_edit.text()
346+
self._update_title()
347+
348+
def _on_content_updated(self, content: str) -> None:
349+
"""Handle in-place text transformation (cleaning, replace-all).
350+
351+
Does NOT clear the modified flag or update _filepath — content was
352+
transformed, not loaded fresh from disk.
353+
"""
354+
self._set_editor_text(content)
244355

245356
def _on_encoding_detected(self, encoding: str) -> None:
246357
self._encoding_label.setText(encoding)
247358

248359
def _on_file_saved(self, filepath: str) -> None:
360+
self._filepath = filepath
361+
self._plain_text_edit.document().setModified(False)
249362
self.ui.statusBar().showMessage(f"Saved: {filepath}")
363+
self._update_title()
250364

251365
def _on_error(self, message: str) -> None:
252366
QMessageBox.critical(self.ui, "Error", message, QMessageBox.StandardButton.Ok)

0 commit comments

Comments
 (0)