diff --git a/.gitignore b/.gitignore index 851b242..2352872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ __pycache__ .pytest_cache dist .coverage -*.db diff --git a/CHANGELOG.md b/CHANGELOG.md index f668299..78fde07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ * Remove screenshot tool * Improve width of bug report dialog * Add Tag relationship visualisation tool - * Improve size of checkboxes - * Convert bullet - to actual unicode bullets # 0.3.2 diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 9212229..3a33363 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -53,10 +53,6 @@ class MarkdownEditor(QTextEdit): self._CHECK_UNCHECKED_STORAGE = "[ ]" self._CHECK_CHECKED_STORAGE = "[x]" - # Bullet character (Unicode for display, "- " for markdown) - self._BULLET_DISPLAY = "•" - self._BULLET_STORAGE = "-" - # Install syntax highlighter self.highlighter = MarkdownHighlighter(self.document(), theme_manager) @@ -262,13 +258,6 @@ class MarkdownEditor(QTextEdit): 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 def _extract_images_to_markdown(self) -> str: @@ -321,14 +310,6 @@ class MarkdownEditor(QTextEdit): 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 try: self.setPlainText(display_text) @@ -411,11 +392,7 @@ class MarkdownEditor(QTextEdit): ): return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ") - # Bullet list – Unicode bullet - if line.startswith(f"{self._BULLET_DISPLAY} "): - return ("bullet", f"{self._BULLET_DISPLAY} ") - - # Bullet list - markdown bullet + # Bullet list if re.match(r"^[-*+]\s", line): match = re.match(r"^([-*+]\s)", line) return ("bullet", match.group(1)) @@ -547,10 +524,7 @@ class MarkdownEditor(QTextEdit): f"{self._CHECK_UNCHECKED_DISPLAY} " ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "): prefix_len = leading_spaces + 2 # icon + space - # Check for Unicode bullet - elif stripped.startswith(f"{self._BULLET_DISPLAY} "): - prefix_len = leading_spaces + 2 # bullet + space - # Check for markdown bullet list (-, *, +) + # Check for bullet list elif re.match(r"^[-*+]\s", stripped): prefix_len = leading_spaces + 2 # marker + space # Check for numbered list @@ -977,19 +951,14 @@ class MarkdownEditor(QTextEdit): QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor ) line = cursor.selectedText() - stripped = line.lstrip() - # Consider existing markdown markers OR our Unicode bullet as "a bullet" - if ( - stripped.startswith(f"{self._BULLET_DISPLAY} ") - 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) + # Check if already a bullet + if line.lstrip().startswith("- ") or line.lstrip().startswith("* "): + # Remove bullet + new_line = re.sub(r"^\s*[-*]\s+", "", line) else: - new_line = f"{self._BULLET_DISPLAY} " + stripped + # Add bullet + new_line = "- " + line.lstrip() cursor.insertText(new_line) diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 35b0045..18dd446 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -98,20 +98,6 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.link_format.setFontUnderline(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 self.syntax_format = QTextCharFormat() # Make the markers invisible by setting font size to 0.1 points @@ -276,11 +262,3 @@ class MarkdownHighlighter(QSyntaxHighlighter): fmt.setAnchorHref(url) # Overlay link attributes on top of whatever formatting is already there 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) diff --git a/bouquin/settings.py b/bouquin/settings.py index 6578237..3a2becc 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -30,7 +30,7 @@ def load_db_config() -> DBConfig: legacy = s.value("db/path", "", type=str) if legacy: path_str = legacy - # migrate and clean up the old key + # Optional: migrate and clean up the old key s.setValue("db/default_db", legacy) s.remove("db/path") path = Path(path_str) if path_str else _default_db_location() diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index e8b0a44..13244f6 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -141,7 +141,7 @@ def test_enter_on_nonempty_list_continues(qtbot, editor): ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") editor.keyPressEvent(ev) txt = editor.toPlainText() - assert "\n\u2022 " in txt + assert "\n- " in txt 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") editor.keyPressEvent(ev) - assert editor.toPlainText().startswith("\u2022 \n") + assert editor.toPlainText().startswith("- \n") def test_triple_backtick_autoexpands(editor, qtbot): diff --git a/tests/test_tag_graph_dialog.py b/tests/test_tag_graph_dialog.py deleted file mode 100644 index a587c9a..0000000 --- a/tests/test_tag_graph_dialog.py +++ /dev/null @@ -1,365 +0,0 @@ -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"]