Fix focusing on editor after leaving the app and returning. More code coverage and removing obsolete bits of code

This commit is contained in:
Miguel Jacq 2025-11-07 13:53:27 +11:00
parent 74177f2cd3
commit aad1ba5d7d
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 264 additions and 100 deletions

View file

@ -1,6 +1,8 @@
# 0.1.11 # 0.1.11
* Add missing export extensions to export_by_extension * 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 # 0.1.10.2

View file

@ -257,67 +257,31 @@ class DBManager:
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_version( def get_version(self, *, version_id: int) -> dict | None:
self,
*,
date_iso: str | None = None,
version_no: int | None = None,
version_id: int | None = None,
) -> 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. Returns a dict with keys: id, date, version_no, created_at, note, content.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
if version_id is not None: row = cur.execute(
row = cur.execute( "SELECT id, date, version_no, created_at, note, content "
"SELECT id, date, version_no, created_at, note, content " "FROM versions WHERE id=?;",
"FROM versions WHERE id=?;", (version_id,),
(version_id,), ).fetchone()
).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()
return dict(row) if row else None return dict(row) if row else None
def revert_to_version( def revert_to_version(self, date_iso: str, version_id: int) -> None:
self,
date_iso: str,
*,
version_no: int | None = None,
version_id: int | None = None,
) -> None:
""" """
Point the page head (pages.current_version_id) to an existing version. 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() cur = self.conn.cursor()
if version_id is None: # Ensure that version_id belongs to the given date
if version_no is None: row = cur.execute(
raise ValueError("Provide version_no or version_id") "SELECT date FROM versions WHERE id=?;", (version_id,)
row = cur.execute( ).fetchone()
"SELECT id FROM versions WHERE date=? AND version_no=?;", if row is None or row["date"] != date_iso:
(date_iso, version_no), raise ValueError("version_id does not belong to the given date")
).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")
with self.conn: with self.conn:
cur.execute( cur.execute(
@ -341,18 +305,13 @@ class DBManager:
).fetchall() ).fetchall()
return [(r[0], r[1]) for r in rows] return [(r[0], r[1]) for r in rows]
def export_json( def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
) -> None:
""" """
Export to json. Export to json.
""" """
data = [{"date": d, "content": c} for d, c in entries] data = [{"date": d, "content": c} for d, c in entries]
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
if pretty: json.dump(data, f, ensure_ascii=False, indent=2)
json.dump(data, f, ensure_ascii=False, indent=2)
else:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None: def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
""" """
@ -500,7 +459,7 @@ class DBManager:
elif ext in {".sql", ".sqlite"}: elif ext in {".sql", ".sqlite"}:
self.export_sql(file_path) self.export_sql(file_path)
elif ext == ".md": elif ext == ".md":
self.export_markdown(file_path) self.export_markdown(entries, file_path)
else: else:
raise ValueError(f"Unsupported extension: {ext}") raise ValueError(f"Unsupported extension: {ext}")

View file

@ -140,10 +140,8 @@ class Editor(QTextEdit):
bc.setPosition(b.position() + b.length()) bc.setPosition(b.position() + b.length())
return blocks > 0 and (codeish / blocks) >= 0.6 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.""" """Walk up parents from the cursor and return the first code frame."""
if cursor is None:
cursor = self.textCursor()
f = cursor.currentFrame() f = cursor.currentFrame()
while f: while f:
if self._is_code_frame(f, tolerant=tolerant): if self._is_code_frame(f, tolerant=tolerant):

View file

@ -26,6 +26,7 @@ from PySide6.QtGui import (
QGuiApplication, QGuiApplication,
QPalette, QPalette,
QTextCharFormat, QTextCharFormat,
QTextCursor,
QTextListFormat, QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -146,6 +147,16 @@ class MainWindow(QMainWindow):
QApplication.instance().installEventFilter(self) 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 # Status bar for feedback
self.statusBar().showMessage("Ready", 800) self.statusBar().showMessage("Ready", 800)
@ -481,7 +492,8 @@ class MainWindow(QMainWindow):
# Inject the extra_data before the closing </body></html> # Inject the extra_data before the closing </body></html>
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
text = modified 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._dirty = True
self._save_date(date_iso, True) self._save_date(date_iso, True)
@ -489,9 +501,7 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self.editor.blockSignals(True) self._set_editor_html_preserve_view(text)
self.editor.setHtml(text)
self.editor.blockSignals(False)
self._dirty = False self._dirty = False
# track which date the editor currently represents # 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): def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress and not self._locked: if event.type() == QEvent.KeyPress and not self._locked:
self._idle_timer.start() self._idle_timer.start()
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
QTimer.singleShot(0, self._focus_editor_now)
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def _enter_lock(self): def _enter_lock(self):
@ -891,6 +903,7 @@ If you want an encrypted backup, choose Backup instead of Export.
if tb: if tb:
tb.setEnabled(True) tb.setEnabled(True)
self._idle_timer.start() self._idle_timer.start()
QTimer.singleShot(0, self._focus_editor_now)
# ----------------- Close handlers ----------------- # # ----------------- Close handlers ----------------- #
def closeEvent(self, event): def closeEvent(self, event):
@ -906,3 +919,61 @@ If you want an encrypted backup, choose Backup instead of Export.
except Exception: except Exception:
pass pass
super().closeEvent(event) 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)

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import pytest 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", "<p>v2</p>", note="edit") ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
assert ver1_no == 1 and ver2_no == 2 assert ver1_no == 1 and ver2_no == 2
# Revert using version_no (exercises branch where version_id is None) # Revert using version_id
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
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id) mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
cur = mgr.conn.cursor()
head2 = cur.execute( head2 = cur.execute(
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",) "SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
).fetchone()[0] ).fetchone()[0]
assert head2 == ver2_id 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 # Error: version_id belongs to a different date
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>") other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
with pytest.raises(ValueError): with pytest.raises(ValueError):

View file

@ -114,7 +114,7 @@ def test_export_by_extension_and_unknown(tmp_path):
import types import types
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) 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}" path = tmp_path / f"route{ext}"
mgr.export_by_extension(str(path)) mgr.export_by_extension(str(path))
assert path.exists() assert path.exists()

View file

@ -145,10 +145,6 @@ def test_linkify_trims_trailing_punctuation(qtbot):
def test_code_block_enter_exits_on_empty_line(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() e = _mk_editor()
qtbot.addWidget(e) qtbot.addWidget(e)

View file

@ -1,9 +1,8 @@
import base64 import base64
from io import BytesIO
import pytest import pytest
from PySide6.QtCore import Qt, QMimeData, QByteArray 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.QtWidgets import QApplication
from PySide6.QtTest import QTest from PySide6.QtTest import QTest

View file

@ -1,6 +1,4 @@
import base64 from PySide6.QtCore import QUrl
from pathlib import Path
from PySide6.QtCore import QUrl, QByteArray
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
from bouquin.theme import ThemeManager from bouquin.theme import ThemeManager
from bouquin.editor import Editor from bouquin.editor import Editor

136
tests/test_editor_more.py Normal file
View file

@ -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 (574584 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 "<img" in e.toHtml()
# Guards when not on an image (453, 464)
e._scale_image_at_cursor(1.1)
e._fit_image_to_editor_width()
def test_checkbox_click_and_enter_continuation(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.setPlainText("☐ task one")
# Need it visible for mouse coords
e.resize(600, 300)
e.show()
qtbot.waitExposed(e)
# Click on the checkbox glyph to toggle (605614)
start_point = _point_for_char(e, 0)
press = QMouseEvent(
QEvent.MouseButtonPress,
start_point,
Qt.LeftButton,
Qt.LeftButton,
Qt.NoModifier,
)
e.mousePressEvent(press)
assert e.toPlainText().startswith("")
# Press Enter at end -> new line with fresh checkbox (680684)
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 (945946)
e.toggle_bullets()
e.toggle_bullets()
# numbers twice -> second call removes (955956)
e.toggle_numbers()
e.toggle_numbers()

View file

@ -1,7 +1,6 @@
import runpy import runpy
import types import types
import sys import sys
import builtins
def test_dunder_main_executes_without_launching_qt(monkeypatch): def test_dunder_main_executes_without_launching_qt(monkeypatch):

View file

@ -1,7 +1,4 @@
import os from PySide6.QtCore import QDate
from datetime import date, timedelta
from pathlib import Path
from PySide6.QtCore import QDate, QByteArray
from bouquin.theme import ThemeManager from bouquin.theme import ThemeManager
from bouquin.main_window import MainWindow from bouquin.main_window import MainWindow
from bouquin.settings import save_db_config from bouquin.settings import save_db_config

View file

@ -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 = "<p>" + ("x" * 300) + "</p>" # 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

View file

@ -1,5 +1,3 @@
import os
import tempfile
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
import pytest 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 # Typing should not raise; exception path returns empty results
w._search("anything") w._search("anything")
assert w.results.isHidden() # remains hidden because there are no rows 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): 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 snippet # non-empty
assert left_ell is True assert left_ell is True
assert right_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()

View file

@ -1,5 +1,4 @@
import pytest import pytest
from PySide6.QtWidgets import QWidget
from bouquin.search import Search from bouquin.search import Search

View file

@ -1,6 +1,4 @@
import types
import pytest import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog, QWidget from PySide6.QtWidgets import QApplication, QDialog, QWidget
from bouquin.db import DBConfig, DBManager from bouquin.db import DBConfig, DBManager