Skip to content

Commit f39d524

Browse files
committed
feat: TextTools v0.3.0 polish pass
- Fix default tab (Clean opens first) - Add Ctrl+F, Ctrl+H, F3 keyboard shortcuts - Normalize ascii encoding label to utf-8 - Implement encoding conversion button (F-001) - Add cursor position + char count to status bar - Implement Save As - Persist window geometry + splitter positions via QSettings - Bump version to 0.3.0
2 parents 806173f + 373beb7 commit f39d524

12 files changed

Lines changed: 921 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
---
1111

12+
## [0.3.0] — 2026-02-21
13+
14+
### Added
15+
- Default tab is now Clean (was Find/Replace)
16+
- Keyboard shortcuts: Ctrl+F (find), Ctrl+H (replace), F3 (find next)
17+
- Encoding conversion button (F-001): re-saves file as UTF-8
18+
- Cursor position and character count in status bar
19+
- Save As (was stubbed)
20+
- Window geometry and splitter positions persist across sessions (QSettings)
21+
22+
### Fixed
23+
- Encoding label now shows "utf-8" for ASCII files (ASCII is a subset of UTF-8)
24+
25+
---
26+
1227
## [0.2.0] - 2026-02-21
1328

1429
### Added

scripts/live_test.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#!/usr/bin/env python
2+
"""Offscreen smoke test for TextTools.
3+
4+
Exercises the full stack (FileService → ViewModel → View widgets) using Qt's
5+
offscreen platform — no display or Xvfb required.
6+
7+
Run with:
8+
uv run python scripts/live_test.py
9+
10+
Exit code: 0 if all checks pass, 1 if any fail.
11+
12+
QT_QPA_PLATFORM=offscreen: renders widgets in memory; all Qt geometry, text
13+
cursor, and signal APIs work identically to a real display, but nothing is
14+
shown on screen. More stable than Xvfb for headless testing because there is
15+
no input method context to trip over.
16+
"""
17+
18+
import os
19+
import sys
20+
from pathlib import Path
21+
from tempfile import TemporaryDirectory
22+
23+
# Must be set before QApplication is created — do not move below imports.
24+
os.environ["QT_QPA_PLATFORM"] = "offscreen"
25+
os.environ["QT_IM_MODULE"] = "none"
26+
27+
# Add project root to sys.path so `src` is importable without install.
28+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
29+
sys.path.insert(0, str(PROJECT_ROOT))
30+
31+
from PySide6.QtWidgets import QApplication # noqa: E402
32+
33+
from src.main import create_application # noqa: E402
34+
35+
_pass = 0
36+
_fail = 0
37+
38+
39+
def check(label: str, condition: bool, detail: str = "") -> None:
40+
global _pass, _fail
41+
if condition:
42+
print(f" PASS {label}")
43+
_pass += 1
44+
else:
45+
note = f" — {detail}" if detail else ""
46+
print(f" FAIL {label}{note}")
47+
_fail += 1
48+
49+
50+
def section(title: str) -> None:
51+
print(f"\n{title}")
52+
print("─" * len(title))
53+
54+
55+
def main() -> None:
56+
app = QApplication(sys.argv)
57+
window = create_application()
58+
59+
with TemporaryDirectory() as tmp:
60+
tmp_path = Path(tmp)
61+
62+
# ----------------------------------------------------------------- load
63+
section("File Loading")
64+
65+
# Non-ASCII content (é, ñ) is required for the encoding check:
66+
# pure ASCII is correctly detected as "ascii" by chardet (ASCII ⊂ UTF-8).
67+
f = tmp_path / "hello.txt"
68+
f.write_text("café résumé — live test", encoding="utf-8")
69+
window._file_name_edit.setText(str(f))
70+
window._viewmodel.load_file(str(f))
71+
app.processEvents()
72+
73+
check(
74+
"Editor populated after load",
75+
window._plain_text_edit.toPlainText() == "café résumé — live test",
76+
)
77+
check(
78+
"Encoding label shows utf-8",
79+
window._encoding_label.text().lower() == "utf-8",
80+
)
81+
check(
82+
"Title bar shows filename",
83+
"hello.txt" in window.ui.windowTitle(),
84+
)
85+
check(
86+
"Title bar has no star (clean after load)",
87+
"*" not in window.ui.windowTitle(),
88+
)
89+
check(
90+
"Status bar mentions filename",
91+
"hello.txt" in window.ui.statusBar().currentMessage(),
92+
)
93+
94+
# ----------------------------------------------------------------- save
95+
section("Save")
96+
97+
out = tmp_path / "out.txt"
98+
window._file_name_edit.setText(str(out))
99+
window._plain_text_edit.setPlainText("saved content")
100+
window._on_save_clicked()
101+
app.processEvents()
102+
103+
check(
104+
"File written to disk",
105+
out.exists(),
106+
)
107+
check(
108+
"File content matches editor",
109+
out.exists() and out.read_text(encoding="utf-8") == "saved content",
110+
)
111+
check(
112+
"Title loses star after save",
113+
"*" not in window.ui.windowTitle(),
114+
)
115+
116+
# -------------------------------------------------------------- cleaning
117+
section("Text Cleaning (real TextProcessingService)")
118+
119+
dirty = tmp_path / "dirty.txt"
120+
dirty.write_text("hello \nworld \n\n", encoding="utf-8")
121+
window._file_name_edit.setText(str(dirty))
122+
window._viewmodel.load_file(str(dirty))
123+
app.processEvents()
124+
125+
window._trim_cb.setChecked(True)
126+
app.processEvents()
127+
128+
trimmed = window._plain_text_edit.toPlainText()
129+
check(
130+
"Trim whitespace strips trailing spaces and blank lines",
131+
trimmed == "hello\nworld",
132+
f"got: {trimmed!r}",
133+
)
134+
135+
window._trim_cb.setChecked(False)
136+
app.processEvents()
137+
138+
# Reload clean content for the next two checks
139+
spaces = tmp_path / "spaces.txt"
140+
spaces.write_text("a b c", encoding="utf-8")
141+
window._file_name_edit.setText(str(spaces))
142+
window._viewmodel.load_file(str(spaces))
143+
app.processEvents()
144+
145+
window._clean_cb.setChecked(True)
146+
app.processEvents()
147+
148+
collapsed = window._plain_text_edit.toPlainText()
149+
check(
150+
"Clean whitespace collapses runs of spaces",
151+
collapsed == "a b c",
152+
f"got: {collapsed!r}",
153+
)
154+
155+
window._clean_cb.setChecked(False)
156+
app.processEvents()
157+
158+
tabs_file = tmp_path / "tabs.txt"
159+
tabs_file.write_text("\tindented\n\t\tdouble", encoding="utf-8")
160+
window._file_name_edit.setText(str(tabs_file))
161+
window._viewmodel.load_file(str(tabs_file))
162+
app.processEvents()
163+
164+
window._remove_tabs_cb.setChecked(True)
165+
app.processEvents()
166+
167+
detabbed = window._plain_text_edit.toPlainText()
168+
check(
169+
"Remove tabs strips leading indent",
170+
detabbed == "indented\ndouble",
171+
f"got: {detabbed!r}",
172+
)
173+
174+
window._remove_tabs_cb.setChecked(False)
175+
app.processEvents()
176+
177+
# --------------------------------------------------------------- find
178+
section("Find")
179+
180+
search = tmp_path / "search.txt"
181+
search.write_text("the quick brown fox", encoding="utf-8")
182+
window._file_name_edit.setText(str(search))
183+
window._viewmodel.load_file(str(search))
184+
app.processEvents()
185+
186+
window._find_edit.setText("brown")
187+
window._on_find_clicked()
188+
selected = window._plain_text_edit.textCursor().selectedText()
189+
check(
190+
"Find selects matching text",
191+
selected == "brown",
192+
f"got: {selected!r}",
193+
)
194+
195+
# Wrap test: move cursor to end, search for earlier word
196+
cursor = window._plain_text_edit.textCursor()
197+
cursor.movePosition(cursor.MoveOperation.End)
198+
window._plain_text_edit.setTextCursor(cursor)
199+
window._find_edit.setText("quick")
200+
window._on_find_clicked()
201+
selected = window._plain_text_edit.textCursor().selectedText()
202+
check(
203+
"Find wraps from end of document",
204+
selected == "quick",
205+
f"got: {selected!r}",
206+
)
207+
208+
# --------------------------------------------------------- replace all
209+
section("Replace All")
210+
211+
rep = tmp_path / "rep.txt"
212+
rep.write_text("cat cat cat", encoding="utf-8")
213+
window._file_name_edit.setText(str(rep))
214+
window._viewmodel.load_file(str(rep))
215+
app.processEvents()
216+
217+
window._find_edit.setText("cat")
218+
window._replace_edit.setText("dog")
219+
window._on_replace_all_clicked()
220+
app.processEvents()
221+
222+
result = window._plain_text_edit.toPlainText()
223+
check(
224+
"Replace All updates all occurrences",
225+
result == "dog dog dog",
226+
f"got: {result!r}",
227+
)
228+
229+
# ------------------------------------------------------- encoding convert
230+
section("Encoding Conversion")
231+
232+
# rep.txt was loaded as UTF-8; clicking Convert on an already-UTF-8
233+
# file must show the "already UTF-8" status message (not save again).
234+
window._convert_button.click()
235+
app.processEvents()
236+
check(
237+
"Convert on UTF-8 file shows already-UTF-8 status",
238+
"already utf-8" in window.ui.statusBar().currentMessage().lower(),
239+
)
240+
241+
# ----------------------------------------------------------------------- summary
242+
total = _pass + _fail
243+
print(f"\n{'=' * 42}")
244+
if _fail == 0:
245+
print(f"All {total} checks passed.")
246+
else:
247+
print(f"{_pass}/{total} passed — {_fail} FAILED.")
248+
print("=" * 42)
249+
250+
window.ui.close()
251+
app.quit()
252+
sys.exit(1 if _fail else 0)
253+
254+
255+
if __name__ == "__main__":
256+
main()

scripts/qt_pilot_launcher.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Qt-pilot test launcher — sets QT_IM_MODULE=none before QApplication is created.
2+
3+
Without this, QTest::keyClicks causes a SIGSEGV on headless displays (Xvfb, etc.)
4+
because there is no input method context. Setting QT_IM_MODULE=none disables the
5+
input method plugin lookup and prevents the null-pointer dereference.
6+
7+
Use with the qt-pilot MCP tool:
8+
launch_app(
9+
script_path="scripts/qt_pilot_launcher.py",
10+
working_dir="/home/chris/projects/TextTools",
11+
python_paths=["/home/chris/projects/TextTools"],
12+
)
13+
"""
14+
15+
import os
16+
17+
# Must be set before QApplication is created — do not move below imports.
18+
os.environ.setdefault("QT_IM_MODULE", "none")
19+
20+
from src.main import main # noqa: E402
21+
22+
if __name__ == "__main__":
23+
main()

src/services/file_service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,17 @@ def _detect_encoding(raw: bytes) -> str:
8080
8181
Module-level so it can be tested without instantiating FileService.
8282
Falls back to utf-8 if chardet is unavailable or confidence is too low.
83+
Normalizes 'ascii' to 'utf-8' because ASCII is a proper subset of UTF-8
84+
and saving as utf-8 is always safe for ASCII content.
8385
"""
8486
try:
8587
import chardet # optional dependency
8688

8789
result = chardet.detect(raw)
8890
if result["encoding"] and result["confidence"] >= _ENCODING_MIN_CONFIDENCE:
89-
return result["encoding"]
91+
detected = result["encoding"].lower()
92+
# ASCII is a valid subset of UTF-8; normalize to avoid confusing the UI.
93+
return "utf-8" if detected == "ascii" else result["encoding"]
9094
except ImportError:
9195
logger.debug("chardet not installed; defaulting to utf-8")
9296
return _ENCODING_FALLBACK

src/utils/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Utility constants for TextTools."""
22

33
APP_NAME = "TextTools"
4-
APP_VERSION = "0.2.0"
4+
APP_VERSION = "0.3.0"
55

66
DEFAULT_WINDOW_WIDTH = 894
77
DEFAULT_WINDOW_HEIGHT = 830

src/viewmodels/main_viewmodel.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,41 @@ def replace_all(
163163
self.content_updated.emit(new_content)
164164
noun = "occurrence" if count == 1 else "occurrences"
165165
self.status_changed.emit(f"Replaced {count} {noun}")
166+
167+
@Slot(str)
168+
def convert_to_utf8(self, current_text: str) -> None:
169+
"""Re-save the current document in UTF-8 encoding.
170+
171+
Args:
172+
current_text: Live editor text from the View. Used as the content
173+
to save (preserves unsaved edits).
174+
175+
No-op when no document is loaded or the file is already UTF-8.
176+
Encoding comparison normalises dashes so 'utf-8' and 'utf8' both match.
177+
"""
178+
if self._current_document is None:
179+
self.status_changed.emit("No document loaded")
180+
return
181+
# Normalise: strip dashes and lowercase so 'UTF-8', 'utf-8', 'utf8' all match.
182+
# UTF-8-SIG is the chardet name for UTF-8 with a BOM — treat it as already UTF-8
183+
# to avoid needlessly re-saving BOM files.
184+
current_encoding = self._current_document.encoding.lower().replace("-", "")
185+
if current_encoding in {"utf8", "utf8sig"}:
186+
self.status_changed.emit("File is already UTF-8")
187+
return
188+
doc = TextDocument(
189+
filepath=self._current_document.filepath,
190+
content=current_text,
191+
encoding="utf-8",
192+
)
193+
try:
194+
self._file_service.save_file(doc)
195+
self._current_document = doc
196+
self.encoding_detected.emit("utf-8")
197+
self.file_saved.emit(doc.filepath)
198+
self.status_changed.emit(f"Converted to UTF-8: {doc.filepath}")
199+
except (ValueError, PermissionError, OSError) as e:
200+
msg = f"Cannot convert file: {e}"
201+
logger.error(msg)
202+
self.error_occurred.emit(msg)
203+
self.status_changed.emit("Error converting file")

0 commit comments

Comments
 (0)