1717from typing import TypeVar , cast
1818
1919from PySide6 .QtCore import QDir , QFile , QModelIndex
20+ from PySide6 .QtGui import QAction
2021from PySide6 .QtUiTools import QUiLoader
2122from PySide6 .QtWidgets import (
23+ QApplication ,
2224 QCheckBox ,
25+ QFileDialog ,
2326 QFileSystemModel ,
2427 QLabel ,
2528 QLineEdit ,
3134)
3235
3336from src .models .cleaning_options import CleaningOptions
37+ from src .utils .constants import APP_VERSION , TEXT_FILE_EXTENSIONS
3438from 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 \n Text 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