Improve size of checkboxes. Convert bullet '-' to actual unicode bullets
All checks were successful
CI / test (push) Successful in 3m30s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 20s

This commit is contained in:
Miguel Jacq 2025-11-18 18:21:09 +11:00
parent a375be629c
commit 63cf561bfe
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 431 additions and 11 deletions

View file

@ -3,6 +3,8 @@
* Remove screenshot tool * Remove screenshot tool
* Improve width of bug report dialog * Improve width of bug report dialog
* Add Tag relationship visualisation tool * Add Tag relationship visualisation tool
* Improve size of checkboxes
* Convert bullet - to actual unicode bullets
# 0.3.2 # 0.3.2

View file

@ -53,6 +53,10 @@ class MarkdownEditor(QTextEdit):
self._CHECK_UNCHECKED_STORAGE = "[ ]" self._CHECK_UNCHECKED_STORAGE = "[ ]"
self._CHECK_CHECKED_STORAGE = "[x]" self._CHECK_CHECKED_STORAGE = "[x]"
# Bullet character (Unicode for display, "- " for markdown)
self._BULLET_DISPLAY = ""
self._BULLET_STORAGE = "-"
# Install syntax highlighter # Install syntax highlighter
self.highlighter = MarkdownHighlighter(self.document(), theme_manager) self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
@ -258,6 +262,13 @@ class MarkdownEditor(QTextEdit):
f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} " f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} "
) )
# Convert Unicode bullets back to "- " at the start of a line
text = re.sub(
rf"(?m)^(\s*){re.escape(self._BULLET_DISPLAY)}\s+",
rf"\1{self._BULLET_STORAGE} ",
text,
)
return text return text
def _extract_images_to_markdown(self) -> str: def _extract_images_to_markdown(self) -> str:
@ -310,6 +321,14 @@ class MarkdownEditor(QTextEdit):
display_text, display_text,
) )
# Convert simple markdown bullets ("- ", "* ", "+ ") to Unicode bullets,
# but skip checkbox lines (- [ ] / - [x])
display_text = re.sub(
r"(?m)^([ \t]*)[-*+]\s+(?!\[[ xX]\])",
rf"\1{self._BULLET_DISPLAY} ",
display_text,
)
self._updating = True self._updating = True
try: try:
self.setPlainText(display_text) self.setPlainText(display_text)
@ -392,7 +411,11 @@ class MarkdownEditor(QTextEdit):
): ):
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
# Bullet list # Bullet list Unicode bullet
if line.startswith(f"{self._BULLET_DISPLAY} "):
return ("bullet", f"{self._BULLET_DISPLAY} ")
# Bullet list - markdown bullet
if re.match(r"^[-*+]\s", line): if re.match(r"^[-*+]\s", line):
match = re.match(r"^([-*+]\s)", line) match = re.match(r"^([-*+]\s)", line)
return ("bullet", match.group(1)) return ("bullet", match.group(1))
@ -524,7 +547,10 @@ class MarkdownEditor(QTextEdit):
f"{self._CHECK_UNCHECKED_DISPLAY} " f"{self._CHECK_UNCHECKED_DISPLAY} "
) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "): ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "):
prefix_len = leading_spaces + 2 # icon + space prefix_len = leading_spaces + 2 # icon + space
# Check for bullet list # Check for Unicode bullet
elif stripped.startswith(f"{self._BULLET_DISPLAY} "):
prefix_len = leading_spaces + 2 # bullet + space
# Check for markdown bullet list (-, *, +)
elif re.match(r"^[-*+]\s", stripped): elif re.match(r"^[-*+]\s", stripped):
prefix_len = leading_spaces + 2 # marker + space prefix_len = leading_spaces + 2 # marker + space
# Check for numbered list # Check for numbered list
@ -951,14 +977,19 @@ class MarkdownEditor(QTextEdit):
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
) )
line = cursor.selectedText() line = cursor.selectedText()
stripped = line.lstrip()
# Check if already a bullet # Consider existing markdown markers OR our Unicode bullet as "a bullet"
if line.lstrip().startswith("- ") or line.lstrip().startswith("* "): if (
# Remove bullet stripped.startswith(f"{self._BULLET_DISPLAY} ")
new_line = re.sub(r"^\s*[-*]\s+", "", line) or stripped.startswith("- ")
or stripped.startswith("* ")
):
# Remove any of those bullet markers
pattern = rf"^\s*([{re.escape(self._BULLET_DISPLAY)}\-*])\s+"
new_line = re.sub(pattern, "", line)
else: else:
# Add bullet new_line = f"{self._BULLET_DISPLAY} " + stripped
new_line = "- " + line.lstrip()
cursor.insertText(new_line) cursor.insertText(new_line)

View file

@ -98,6 +98,20 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.link_format.setFontUnderline(True) self.link_format.setFontUnderline(True)
self.link_format.setAnchor(True) self.link_format.setAnchor(True)
# Base size from the document/editor font
doc = self.document()
base_font = doc.defaultFont() if doc is not None else QGuiApplication.font()
base_size = base_font.pointSizeF()
if base_size <= 0:
base_size = 10.0 # fallback
# Checkboxes: make them a bit bigger so they stand out
self.checkbox_format = QTextCharFormat()
self.checkbox_format.setFontPointSize(base_size * 1.4)
# Bullets
self.bullet_format = QTextCharFormat()
self.bullet_format.setFontPointSize(base_size * 1.2)
# Markdown syntax (the markers themselves) - make invisible # Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat() self.syntax_format = QTextCharFormat()
# Make the markers invisible by setting font size to 0.1 points # Make the markers invisible by setting font size to 0.1 points
@ -262,3 +276,11 @@ class MarkdownHighlighter(QSyntaxHighlighter):
fmt.setAnchorHref(url) fmt.setAnchorHref(url)
# Overlay link attributes on top of whatever formatting is already there # Overlay link attributes on top of whatever formatting is already there
self._overlay_range(start, end - start, fmt) self._overlay_range(start, end - start, fmt)
# Make checkbox glyphs bigger
for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format)

View file

@ -30,7 +30,7 @@ def load_db_config() -> DBConfig:
legacy = s.value("db/path", "", type=str) legacy = s.value("db/path", "", type=str)
if legacy: if legacy:
path_str = legacy path_str = legacy
# Optional: migrate and clean up the old key # migrate and clean up the old key
s.setValue("db/default_db", legacy) s.setValue("db/default_db", legacy)
s.remove("db/path") s.remove("db/path")
path = Path(path_str) if path_str else _default_db_location() path = Path(path_str) if path_str else _default_db_location()

View file

@ -141,7 +141,7 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev) editor.keyPressEvent(ev)
txt = editor.toPlainText() txt = editor.toPlainText()
assert "\n- " in txt assert "\n\u2022 " in txt
def test_enter_on_empty_list_marks_empty(qtbot, editor): def test_enter_on_empty_list_marks_empty(qtbot, editor):
@ -154,7 +154,7 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev) editor.keyPressEvent(ev)
assert editor.toPlainText().startswith("- \n") assert editor.toPlainText().startswith("\u2022 \n")
def test_triple_backtick_autoexpands(editor, qtbot): def test_triple_backtick_autoexpands(editor, qtbot):

View file

@ -0,0 +1,365 @@
import numpy as np
import pytest
from pyqtgraph.Qt import QtCore
from bouquin.tag_graph_dialog import TagGraphDialog, DraggableGraphItem
# ---------------------------------------------------------------------------
# Helpers for DraggableGraphItem tests
# ---------------------------------------------------------------------------
class DummyPos:
"""Simple object with x()/y() so it looks like a QPointF to the code."""
def __init__(self, x, y):
self._x = float(x)
self._y = float(y)
def x(self):
return self._x
def y(self):
return self._y
class FakePoint:
"""Minimal object returned from scatter.pointsAt()."""
def __init__(self, idx):
self._idx = idx
def index(self):
return self._idx
class FakeDragEvent:
"""Stub object that looks like pyqtgraph's mouse drag event."""
def __init__(self, stage, button, start_pos, move_pos):
# stage is one of: "start", "move", "finish"
self._stage = stage
self._button = button
self._start_pos = start_pos
self._pos = move_pos
self.accepted = False
self.ignored = False
# Life-cycle ---------------------------------------------------------
def isStart(self):
return self._stage == "start"
def isFinish(self):
return self._stage == "finish"
# Buttons / positions ------------------------------------------------
def button(self):
return self._button
def buttonDownPos(self):
return self._start_pos
def pos(self):
return self._pos
# Accept / ignore ----------------------------------------------------
def accept(self):
self.accepted = True
def ignore(self):
self.ignored = True
class FakeHoverEvent:
"""Stub for hoverEvent tests."""
def __init__(self, pos=None, exit=False):
self._pos = pos
self._exit = exit
def isExit(self):
return self._exit
def pos(self):
return self._pos
# ---------------------------------------------------------------------------
# DraggableGraphItem
# ---------------------------------------------------------------------------
def test_draggable_graph_item_setdata_caches_kwargs(app):
item = DraggableGraphItem()
pos1 = np.array([[0.0, 0.0]], dtype=float)
adj = np.zeros((0, 2), dtype=int)
sizes = np.array([5.0], dtype=float)
# First call sets all kwargs
item.setData(pos=pos1, adj=adj, size=sizes)
assert item.pos is pos1
assert "adj" in item._data_kwargs
assert "size" in item._data_kwargs
# Second call only passes pos; cached kwargs should keep size/adj
pos2 = np.array([[1.0, 1.0]], dtype=float)
item.setData(pos=pos2)
assert item.pos is pos2
assert item._data_kwargs["adj"] is adj
# size should still be present and unchanged
assert np.all(item._data_kwargs["size"] == sizes)
def test_draggable_graph_item_drag_updates_position_and_calls_callback(app):
moved = []
def on_pos_changed(pos):
# Store a copy so later mutations don't affect our assertion
moved.append(np.array(pos, copy=True))
item = DraggableGraphItem(on_position_changed=on_pos_changed)
# Simple 2-node graph
pos = np.array([[0.0, 0.0], [5.0, 5.0]], dtype=float)
adj = np.array([[0, 1]], dtype=int)
item.setData(pos=pos, adj=adj, size=np.array([5.0, 5.0], dtype=float))
# Make pointsAt always return the first node
item.scatter.pointsAt = lambda p: [FakePoint(0)]
# Start drag on node 0 at (0, 0)
start_ev = FakeDragEvent(
stage="start",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=None,
)
item.mouseDragEvent(start_ev)
assert item._drag_index == 0
assert start_ev.accepted
assert not start_ev.ignored
# Move mouse to (2, 3) node 0 should follow exactly
move_ev = FakeDragEvent(
stage="move",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=DummyPos(2.0, 3.0),
)
item.mouseDragEvent(move_ev)
assert move_ev.accepted
assert not move_ev.ignored
assert item.pos.shape == (2, 2)
assert item.pos[0, 0] == pytest.approx(2.0)
assert item.pos[0, 1] == pytest.approx(3.0)
# Callback should have been invoked with the updated positions
assert moved, "on_position_changed should be called at least once"
np.testing.assert_allclose(moved[-1][0], [2.0, 3.0], atol=1e-6)
# Finish drag: internal state should reset
finish_ev = FakeDragEvent(
stage="finish",
button=QtCore.Qt.MouseButton.LeftButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=DummyPos(2.0, 3.0),
)
item.mouseDragEvent(finish_ev)
assert finish_ev.accepted
assert item._drag_index is None
assert item._drag_offset is None
def test_draggable_graph_item_ignores_non_left_button(app):
item = DraggableGraphItem()
pos = np.array([[0.0, 0.0]], dtype=float)
adj = np.zeros((0, 2), dtype=int)
item.setData(pos=pos, adj=adj, size=np.array([5.0], dtype=float))
# pointsAt would return something, but the button is not left,
# so the event should be ignored.
item.scatter.pointsAt = lambda p: [FakePoint(0)]
ev = FakeDragEvent(
stage="start",
button=QtCore.Qt.MouseButton.RightButton,
start_pos=DummyPos(0.0, 0.0),
move_pos=None,
)
item.mouseDragEvent(ev)
assert ev.ignored
assert not ev.accepted
assert item._drag_index is None
def test_draggable_graph_item_hover_reports_index_and_exit(app):
hovered = []
def on_hover(idx, ev):
hovered.append(idx)
item = DraggableGraphItem(on_hover=on_hover)
# Case 1: exit event should report None
ev_exit = FakeHoverEvent(exit=True)
item.hoverEvent(ev_exit)
assert hovered[-1] is None
# Case 2: no points under mouse -> None
item.scatter.pointsAt = lambda p: []
ev_none = FakeHoverEvent(pos=DummyPos(0.0, 0.0))
item.hoverEvent(ev_none)
assert hovered[-1] is None
# Case 3: one point under mouse -> its index
item.scatter.pointsAt = lambda p: [FakePoint(3)]
ev_hit = FakeHoverEvent(pos=DummyPos(1.0, 2.0))
item.hoverEvent(ev_hit)
assert hovered[-1] == 3
# ---------------------------------------------------------------------------
# TagGraphDialog
# ---------------------------------------------------------------------------
class EmptyTagDB:
"""DB stub that returns no tag data."""
def get_tag_cooccurrences(self):
return {}, [], {}
class SimpleTagDB:
"""Deterministic stub for tag co-occurrence data."""
def __init__(self):
self.called = False
def get_tag_cooccurrences(self):
self.called = True
tags_by_id = {
1: (1, "alpha", "#ff0000"),
2: (2, "beta", "#00ff00"),
3: (3, "gamma", "#0000ff"),
}
edges = [
(1, 2, 3),
(2, 3, 1),
]
tag_page_counts = {1: 5, 2: 3, 3: 1}
return tags_by_id, edges, tag_page_counts
def test_tag_graph_dialog_handles_empty_db(app, qtbot):
dlg = TagGraphDialog(EmptyTagDB())
qtbot.addWidget(dlg)
dlg.show()
# When there are no tags, nothing should be populated
assert dlg._tag_ids == []
assert dlg._label_items == []
assert dlg._tag_names == {}
assert dlg._tag_page_counts == {}
def test_tag_graph_dialog_populates_graph_from_db(app, qtbot):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert db.called
# Basic invariants about the populated state
assert dlg._tag_ids == [1, 2, 3]
assert dlg._tag_names[1] == "alpha"
assert dlg._tag_page_counts[1] == 5
# GraphItem should have one position per node
assert dlg.graph_item.pos.shape == (3, 2)
# Labels and halo state should match number of tags
assert len(dlg._label_items) == 3
assert len(dlg._halo_sizes) == 3
assert len(dlg._halo_brushes) == 3
def test_tag_graph_dialog_on_positions_changed_updates_labels_and_halo(
app, qtbot, monkeypatch
):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert len(dlg._label_items) == 3
# Set up fake halo sizes/brushes so the halo branch runs
dlg._halo_sizes = [10.0, 20.0, 30.0]
dlg._halo_brushes = ["a", "b", "c"]
captured = {}
def fake_set_data(*, x, y, size, brush, pen):
captured["x"] = x
captured["y"] = y
captured["size"] = size
captured["brush"] = brush
captured["pen"] = pen
monkeypatch.setattr(dlg._halo_item, "setData", fake_set_data)
# New layout positions
pos = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=float)
dlg._on_positions_changed(pos)
# Each label should be slightly below its node (y + 0.30)
for i, label in enumerate(dlg._label_items):
assert label.pos().x() == pytest.approx(pos[i, 0])
assert label.pos().y() == pytest.approx(pos[i, 1] + 0.30)
# Halo layer should receive the updated coordinates and our sizes/brushes
assert captured["x"] == [1.0, 3.0, 5.0]
assert captured["y"] == [2.0, 4.0, 6.0]
assert captured["size"] == dlg._halo_sizes
assert captured["brush"] == dlg._halo_brushes
assert captured["pen"] is None
def test_tag_graph_dialog_hover_index_shows_and_hides_tooltip(app, qtbot, monkeypatch):
db = SimpleTagDB()
dlg = TagGraphDialog(db)
qtbot.addWidget(dlg)
dlg.show()
shown = {}
hidden = {"called": False}
def fake_show_text(pos, text, widget):
shown["pos"] = pos
shown["text"] = text
shown["widget"] = widget
def fake_hide_text():
hidden["called"] = True
# Patch the module-level QToolTip used by TagGraphDialog
monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.showText", fake_show_text)
monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.hideText", fake_hide_text)
# Hover over first node (index 0)
dlg._on_hover_index(0, ev=None)
assert "alpha" in shown["text"]
assert "page" in shown["text"]
assert shown["widget"] is dlg
# Now simulate leaving the item entirely
dlg._on_hover_index(None, ev=None)
assert hidden["called"]