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
* 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

View file

@ -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}")

View file

@ -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):

View file

@ -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 </body></html>
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)

View file

@ -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", "<p>v2</p>", 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", "<p>other</p>")
with pytest.raises(ValueError):

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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

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 types
import sys
import builtins
def test_dunder_main_executes_without_launching_qt(monkeypatch):

View file

@ -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

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
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()

View file

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

View file

@ -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