From aad1ba5d7d515ab3df52066aea31077f581242b9 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Fri, 7 Nov 2025 13:53:27 +1100 Subject: [PATCH] Fix focusing on editor after leaving the app and returning. More code coverage and removing obsolete bits of code --- CHANGELOG.md | 2 + bouquin/db.py | 75 +++--------- bouquin/editor.py | 4 +- bouquin/main_window.py | 79 +++++++++++- tests/test_db_migrations_and_versions.py | 16 +-- tests/test_db_unit.py | 2 +- tests/test_editor.py | 4 - tests/test_editor_features_more.py | 3 +- tests/test_editor_images_text_states.py | 4 +- tests/test_editor_more.py | 136 +++++++++++++++++++++ tests/test_main_module.py | 1 - tests/test_main_window_actions.py | 5 +- tests/test_search_edgecase.py | 15 +++ tests/test_search_edges.py | 15 ++- tests/test_search_windows.py | 1 - tests/test_settings_dialog_cancel_paths.py | 2 - 16 files changed, 264 insertions(+), 100 deletions(-) create mode 100644 tests/test_editor_more.py create mode 100644 tests/test_search_edgecase.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7243f00..acf8dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # 0.1.11 * Add missing export extensions to export_by_extension + * Fix focusing on editor after leaving the app and returning + * More code coverage and removing obsolete bits of code # 0.1.10.2 diff --git a/bouquin/db.py b/bouquin/db.py index 7295b0a..b6c937b 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -257,67 +257,31 @@ class DBManager: ).fetchall() return [dict(r) for r in rows] - def get_version( - self, - *, - date_iso: str | None = None, - version_no: int | None = None, - version_id: int | None = None, - ) -> dict | None: + def get_version(self, *, version_id: int) -> dict | None: """ - Fetch a specific version by (date, version_no) OR by version_id. + Fetch a specific version by version_id. Returns a dict with keys: id, date, version_no, created_at, note, content. """ cur = self.conn.cursor() - if version_id is not None: - row = cur.execute( - "SELECT id, date, version_no, created_at, note, content " - "FROM versions WHERE id=?;", - (version_id,), - ).fetchone() - else: - if date_iso is None or version_no is None: - raise ValueError( - "Provide either version_id OR (date_iso and version_no)" - ) - row = cur.execute( - "SELECT id, date, version_no, created_at, note, content " - "FROM versions WHERE date=? AND version_no=?;", - (date_iso, version_no), - ).fetchone() + row = cur.execute( + "SELECT id, date, version_no, created_at, note, content " + "FROM versions WHERE id=?;", + (version_id,), + ).fetchone() return dict(row) if row else None - def revert_to_version( - self, - date_iso: str, - *, - version_no: int | None = None, - version_id: int | None = None, - ) -> None: + def revert_to_version(self, date_iso: str, version_id: int) -> None: """ Point the page head (pages.current_version_id) to an existing version. """ - if self.conn is None: - raise RuntimeError("Database is not connected") cur = self.conn.cursor() - if version_id is None: - if version_no is None: - raise ValueError("Provide version_no or version_id") - row = cur.execute( - "SELECT id FROM versions WHERE date=? AND version_no=?;", - (date_iso, version_no), - ).fetchone() - if row is None: - raise ValueError("Version not found for this date") - version_id = int(row["id"]) - else: - # Ensure that version_id belongs to the given date - row = cur.execute( - "SELECT date FROM versions WHERE id=?;", (version_id,) - ).fetchone() - if row is None or row["date"] != date_iso: - raise ValueError("version_id does not belong to the given date") + # Ensure that version_id belongs to the given date + row = cur.execute( + "SELECT date FROM versions WHERE id=?;", (version_id,) + ).fetchone() + if row is None or row["date"] != date_iso: + raise ValueError("version_id does not belong to the given date") with self.conn: cur.execute( @@ -341,18 +305,13 @@ class DBManager: ).fetchall() return [(r[0], r[1]) for r in rows] - def export_json( - self, entries: Sequence[Entry], file_path: str, pretty: bool = True - ) -> None: + def export_json(self, entries: Sequence[Entry], file_path: str) -> None: """ Export to json. """ data = [{"date": d, "content": c} for d, c in entries] with open(file_path, "w", encoding="utf-8") as f: - if pretty: - json.dump(data, f, ensure_ascii=False, indent=2) - else: - json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + json.dump(data, f, ensure_ascii=False, indent=2) def export_csv(self, entries: Sequence[Entry], file_path: str) -> None: """ @@ -500,7 +459,7 @@ class DBManager: elif ext in {".sql", ".sqlite"}: self.export_sql(file_path) elif ext == ".md": - self.export_markdown(file_path) + self.export_markdown(entries, file_path) else: raise ValueError(f"Unsupported extension: {ext}") diff --git a/bouquin/editor.py b/bouquin/editor.py index ff3504b..e3f7133 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -140,10 +140,8 @@ class Editor(QTextEdit): bc.setPosition(b.position() + b.length()) return blocks > 0 and (codeish / blocks) >= 0.6 - def _nearest_code_frame(self, cursor=None, tolerant: bool = False): + def _nearest_code_frame(self, cursor, tolerant: bool = False): """Walk up parents from the cursor and return the first code frame.""" - if cursor is None: - cursor = self.textCursor() f = cursor.currentFrame() while f: if self._is_code_frame(f, tolerant=tolerant): diff --git a/bouquin/main_window.py b/bouquin/main_window.py index babb7e4..d00e013 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -26,6 +26,7 @@ from PySide6.QtGui import ( QGuiApplication, QPalette, QTextCharFormat, + QTextCursor, QTextListFormat, ) from PySide6.QtWidgets import ( @@ -146,6 +147,16 @@ class MainWindow(QMainWindow): QApplication.instance().installEventFilter(self) + # Focus on the editor + self.setFocusPolicy(Qt.StrongFocus) + self.editor.setFocusPolicy(Qt.StrongFocus) + self.toolBar.setFocusPolicy(Qt.NoFocus) + for w in self.toolBar.findChildren(QWidget): + w.setFocusPolicy(Qt.NoFocus) + QGuiApplication.instance().applicationStateChanged.connect( + self._on_app_state_changed + ) + # Status bar for feedback self.statusBar().showMessage("Ready", 800) @@ -481,7 +492,8 @@ class MainWindow(QMainWindow): # Inject the extra_data before the closing modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) text = modified - self.editor.setHtml(text) + # Force a save now so we don't lose it. + self._set_editor_html_preserve_view(text) self._dirty = True self._save_date(date_iso, True) @@ -489,9 +501,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Read Error", str(e)) return - self.editor.blockSignals(True) - self.editor.setHtml(text) - self.editor.blockSignals(False) + self._set_editor_html_preserve_view(text) self._dirty = False # track which date the editor currently represents @@ -850,6 +860,8 @@ If you want an encrypted backup, choose Backup instead of Export. def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() + if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): + QTimer.singleShot(0, self._focus_editor_now) return super().eventFilter(obj, event) def _enter_lock(self): @@ -891,6 +903,7 @@ If you want an encrypted backup, choose Backup instead of Export. if tb: tb.setEnabled(True) self._idle_timer.start() + QTimer.singleShot(0, self._focus_editor_now) # ----------------- Close handlers ----------------- # def closeEvent(self, event): @@ -906,3 +919,61 @@ If you want an encrypted backup, choose Backup instead of Export. except Exception: pass super().closeEvent(event) + + # ----------------- Below logic helps focus the editor ----------------- # + + def _focus_editor_now(self): + """Give focus to the editor and ensure the caret is visible.""" + if getattr(self, "_locked", False): + return + if not self.isActiveWindow(): + return + # Belt-and-suspenders: do it now and once more on the next tick + self.editor.setFocus(Qt.ActiveWindowFocusReason) + self.editor.ensureCursorVisible() + QTimer.singleShot( + 0, + lambda: ( + self.editor.setFocus(Qt.ActiveWindowFocusReason), + self.editor.ensureCursorVisible(), + ), + ) + + def _on_app_state_changed(self, state): + # Called on macOS/Wayland/Windows when the whole app re-activates + if state == Qt.ApplicationActive and self.isActiveWindow(): + QTimer.singleShot(0, self._focus_editor_now) + + def changeEvent(self, ev): + # Called on some platforms when the window's activation state flips + super().changeEvent(ev) + if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): + QTimer.singleShot(0, self._focus_editor_now) + + def _set_editor_html_preserve_view(self, html: str): + ed = self.editor + + # Save caret/selection and scroll + cur = ed.textCursor() + old_pos, old_anchor = cur.position(), cur.anchor() + v = ed.verticalScrollBar().value() + h = ed.horizontalScrollBar().value() + + # Only touch the doc if it actually changed + ed.blockSignals(True) + if ed.toHtml() != html: + ed.setHtml(html) + ed.blockSignals(False) + + # Restore scroll first + ed.verticalScrollBar().setValue(v) + ed.horizontalScrollBar().setValue(h) + + # Restore caret/selection + cur = ed.textCursor() + cur.setPosition(old_anchor) + mode = ( + QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor + ) + cur.setPosition(old_pos, mode) + ed.setTextCursor(cur) diff --git a/tests/test_db_migrations_and_versions.py b/tests/test_db_migrations_and_versions.py index 3ac325d..8fd1166 100644 --- a/tests/test_db_migrations_and_versions.py +++ b/tests/test_db_migrations_and_versions.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from pathlib import Path import pytest @@ -74,25 +73,14 @@ def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig): ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "

v2

", note="edit") assert ver1_no == 1 and ver2_no == 2 - # Revert using version_no (exercises branch where version_id is None) - mgr.revert_to_version(date_iso="2025-01-04", version_no=1, version_id=None) - cur = mgr.conn.cursor() - head = cur.execute( - "SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",) - ).fetchone()[0] - assert head == ver1_id - - # Revert using version_id directly should also work + # Revert using version_id mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id) + cur = mgr.conn.cursor() head2 = cur.execute( "SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",) ).fetchone()[0] assert head2 == ver2_id - # Error: version not found for date (non-existent version_no) - with pytest.raises(ValueError): - mgr.revert_to_version(date_iso="2025-01-04", version_no=99) - # Error: version_id belongs to a different date other_id, _ = mgr.save_new_version("2025-01-05", "

other

") with pytest.raises(ValueError): diff --git a/tests/test_db_unit.py b/tests/test_db_unit.py index d369abf..8c80160 100644 --- a/tests/test_db_unit.py +++ b/tests/test_db_unit.py @@ -114,7 +114,7 @@ def test_export_by_extension_and_unknown(tmp_path): import types mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) - for ext in [".json", ".csv", ".txt", ".html"]: + for ext in [".json", ".csv", ".txt", ".html", ".md"]: path = tmp_path / f"route{ext}" mgr.export_by_extension(str(path)) assert path.exists() diff --git a/tests/test_editor.py b/tests/test_editor.py index 520e941..e0951b8 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -145,10 +145,6 @@ def test_linkify_trims_trailing_punctuation(qtbot): def test_code_block_enter_exits_on_empty_line(qtbot): - from PySide6.QtCore import Qt - from PySide6.QtGui import QTextCursor - from PySide6.QtTest import QTest - from bouquin.editor import Editor e = _mk_editor() qtbot.addWidget(e) diff --git a/tests/test_editor_features_more.py b/tests/test_editor_features_more.py index 529019b..4d905f7 100644 --- a/tests/test_editor_features_more.py +++ b/tests/test_editor_features_more.py @@ -1,9 +1,8 @@ import base64 -from io import BytesIO import pytest from PySide6.QtCore import Qt, QMimeData, QByteArray -from PySide6.QtGui import QImage, QPixmap, QKeyEvent, QTextCursor +from PySide6.QtGui import QImage, QTextCursor from PySide6.QtWidgets import QApplication from PySide6.QtTest import QTest diff --git a/tests/test_editor_images_text_states.py b/tests/test_editor_images_text_states.py index 186572a..8cb81d9 100644 --- a/tests/test_editor_images_text_states.py +++ b/tests/test_editor_images_text_states.py @@ -1,6 +1,4 @@ -import base64 -from pathlib import Path -from PySide6.QtCore import QUrl, QByteArray +from PySide6.QtCore import QUrl from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor from bouquin.theme import ThemeManager from bouquin.editor import Editor diff --git a/tests/test_editor_more.py b/tests/test_editor_more.py new file mode 100644 index 0000000..fd015a9 --- /dev/null +++ b/tests/test_editor_more.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot +from PySide6.QtGui import QImage, QMouseEvent, QTextCursor +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from bouquin.editor import Editor +from bouquin.theme import ThemeManager, ThemeConfig + + +def _mk_editor() -> Editor: + app = QApplication.instance() + tm = ThemeManager(app, ThemeConfig()) + e = Editor(tm) + e.resize(700, 400) + e.show() + return e + + +def _point_for_char(e: Editor, pos: int): + c = e.textCursor() + c.setPosition(pos) + r = e.cursorRect(c) + return r.center() + + +def test_trim_url_and_linkify_and_ctrl_mouse(qtbot): + e = _mk_editor() + qtbot.addWidget(e) + assert e._trim_url_end("https://ex.com)") == "https://ex.com" + assert e._trim_url_end("www.mysite.org]") == "www.mysite.org" + + url = "https://example.org/path" + QTest.keyClicks(e, url) + qtbot.waitUntil(lambda: url in e.toPlainText()) + + p = _point_for_char(e, 0) + move = QMouseEvent( + QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier + ) + e.mouseMoveEvent(move) + assert e.viewport().cursor().shape() == Qt.PointingHandCursor + + opened = {} + + class Catcher(QObject): + @Slot(QUrl) + def handle(self, u: QUrl): + opened["u"] = u.toString() + + from PySide6.QtGui import QDesktopServices + + catcher = Catcher() + QDesktopServices.setUrlHandler("https", catcher, "handle") + try: + rel = QMouseEvent( + QEvent.MouseButtonRelease, + p, + Qt.LeftButton, + Qt.LeftButton, + Qt.ControlModifier, + ) + e.mouseReleaseEvent(rel) + got_signal = [] + e.linkActivated.connect(lambda href: got_signal.append(href)) + e.mouseReleaseEvent(rel) + assert opened or got_signal + finally: + QDesktopServices.unsetUrlHandler("https") + + +def test_insert_images_and_image_helpers(qtbot, tmp_path): + e = _mk_editor() + qtbot.addWidget(e) + + # No image under cursor yet (412 guard) + tc, fmt, orig = e._image_info_at_cursor() + assert tc is None and fmt is None and orig is None + + # Insert a real image file (574–584 path) + img_path = tmp_path / "tiny.png" + img = QImage(4, 4, QImage.Format_ARGB32) + img.fill(0xFF336699) + assert img.save(str(img_path), "PNG") + e.insert_images([str(img_path)], autoscale=False) + assert " new line with fresh checkbox (680–684) + c = e.textCursor() + c.movePosition(QTextCursor.End) + e.setTextCursor(c) + QTest.keyClick(e, Qt.Key_Return) + lines = e.toPlainText().splitlines() + assert len(lines) >= 2 and lines[1].startswith("☐ ") + + +def test_heading_and_lists_toggle_remove(qtbot): + e = _mk_editor() + qtbot.addWidget(e) + e.setPlainText("para") + + # "Normal" path is size=0 (904…) + e.apply_heading(0) + + # bullets twice -> second call removes (945–946) + e.toggle_bullets() + e.toggle_bullets() + # numbers twice -> second call removes (955–956) + e.toggle_numbers() + e.toggle_numbers() diff --git a/tests/test_main_module.py b/tests/test_main_module.py index b2a63ae..6d596b3 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -1,7 +1,6 @@ import runpy import types import sys -import builtins def test_dunder_main_executes_without_launching_qt(monkeypatch): diff --git a/tests/test_main_window_actions.py b/tests/test_main_window_actions.py index 2356abf..2630830 100644 --- a/tests/test_main_window_actions.py +++ b/tests/test_main_window_actions.py @@ -1,7 +1,4 @@ -import os -from datetime import date, timedelta -from pathlib import Path -from PySide6.QtCore import QDate, QByteArray +from PySide6.QtCore import QDate from bouquin.theme import ThemeManager from bouquin.main_window import MainWindow from bouquin.settings import save_db_config diff --git a/tests/test_search_edgecase.py b/tests/test_search_edgecase.py new file mode 100644 index 0000000..712f7e3 --- /dev/null +++ b/tests/test_search_edgecase.py @@ -0,0 +1,15 @@ +from bouquin.search import Search as SearchWidget + + +class DummyDB: + def search_entries(self, q): + return [] + + +def test_make_html_snippet_no_match_triggers_start_window(qtbot): + w = SearchWidget(db=DummyDB()) + qtbot.addWidget(w) + html = "

" + ("x" * 300) + "

" # long text, no token present + frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80) + assert frag != "" + assert left is False and right is True diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py index 1a4caaa..b3a6751 100644 --- a/tests/test_search_edges.py +++ b/tests/test_search_edges.py @@ -1,5 +1,3 @@ -import os -import tempfile from PySide6.QtWidgets import QApplication import pytest @@ -47,7 +45,6 @@ def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, # Typing should not raise; exception path returns empty results w._search("anything") assert w.results.isHidden() # remains hidden because there are no rows - # Also, the "resultDatesChanged" signal should emit an empty list (coverage on that branch) def test_make_html_snippet_ellipses_both_sides(app, fresh_db): @@ -59,3 +56,15 @@ def test_make_html_snippet_ellipses_both_sides(app, fresh_db): assert snippet # non-empty assert left_ell is True assert right_ell is True + + +def test_search_results_middle(app, fresh_db, qtbot): + w = Search(fresh_db) + w.show() + qtbot.addWidget(w) + # Choose a query so that the first match sits well inside a long string, + # forcing both left and right ellipses. + assert fresh_db.connect() + + w._search("middle") + assert w.results.isVisible() diff --git a/tests/test_search_windows.py b/tests/test_search_windows.py index 87ddfe8..5770e73 100644 --- a/tests/test_search_windows.py +++ b/tests/test_search_windows.py @@ -1,5 +1,4 @@ import pytest -from PySide6.QtWidgets import QWidget from bouquin.search import Search diff --git a/tests/test_settings_dialog_cancel_paths.py b/tests/test_settings_dialog_cancel_paths.py index 0362f42..bd86325 100644 --- a/tests/test_settings_dialog_cancel_paths.py +++ b/tests/test_settings_dialog_cancel_paths.py @@ -1,6 +1,4 @@ -import types import pytest -from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QDialog, QWidget from bouquin.db import DBConfig, DBManager