Fix focusing on editor after leaving the app and returning. More code coverage and removing obsolete bits of code
This commit is contained in:
parent
74177f2cd3
commit
aad1ba5d7d
16 changed files with 264 additions and 100 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -257,61 +257,25 @@ 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()
|
||||
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,)
|
||||
|
|
@ -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=(",", ":"))
|
||||
|
||||
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
136
tests/test_editor_more.py
Normal 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 (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 "<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 (605–614)
|
||||
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 (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()
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import runpy
|
||||
import types
|
||||
import sys
|
||||
import builtins
|
||||
|
||||
|
||||
def test_dunder_main_executes_without_launching_qt(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
tests/test_search_edgecase.py
Normal file
15
tests/test_search_edgecase.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.search import Search
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue