diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e502e..2b35218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Make it possible to add a tag from the Tag Browser * Add a statistics dialog with heatmap * Remove export to .txt (just use .md) + * Restore link styling and clickability # 0.3 diff --git a/bouquin/main_window.py b/bouquin/main_window.py index c499a13..6a64888 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -440,6 +440,7 @@ class MainWindow(QMainWindow): return self._create_new_tab(date) def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor: + """Create a new editor tab and return the editor instance.""" if date is None: date = self.calendar.selectedDate() @@ -449,7 +450,6 @@ class MainWindow(QMainWindow): self.tab_widget.setCurrentIndex(existing) return self.tab_widget.widget(existing) - """Create a new editor tab and return the editor instance.""" editor = MarkdownEditor(self.themes) # Set up the editor's event connections @@ -1129,6 +1129,15 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() + # ------------ Statistics handler --------------- # + + def _open_statistics(self): + if not getattr(self, "db", None) or self.db.conn is None: + return + + dlg = StatisticsDialog(self.db, self) + dlg.exec() + # ------------ Window positioning --------------- # def _restore_window_position(self): geom = self.settings.value("main/geometry", None) @@ -1434,11 +1443,3 @@ class MainWindow(QMainWindow): super().changeEvent(ev) if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now) - - def _open_statistics(self): - # If the DB isn't ready for some reason, just do nothing - if not getattr(self, "db", None) or self.db.conn is None: - return - - dlg = StatisticsDialog(self.db, self) - dlg.exec() diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 5415509..19742e2 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -14,8 +14,9 @@ from PySide6.QtGui import ( QTextFormat, QTextBlockFormat, QTextImageFormat, + QDesktopServices, ) -from PySide6.QtCore import Qt, QRect, QTimer +from PySide6.QtCore import Qt, QRect, QTimer, QUrl from PySide6.QtWidgets import QTextEdit from .theme import ThemeManager @@ -32,6 +33,9 @@ class MarkdownEditor(QTextEdit): self.theme_manager = theme_manager + # Track hyperlink under click + self._clicked_link: str | None = None + # Setup tab width tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") self.setTabStopDistance(tab_w) @@ -70,6 +74,11 @@ class MarkdownEditor(QTextEdit): # Enable mouse tracking for checkbox clicking self.viewport().setMouseTracking(True) + # Also mark links as mouse-accessible + flags = self.textInteractionFlags() + self.setTextInteractionFlags( + flags | Qt.TextInteractionFlag.LinksAccessibleByMouse + ) def setDocument(self, doc): super().setDocument(doc) @@ -400,6 +409,28 @@ class MarkdownEditor(QTextEdit): return (None, "") + def _url_at_pos(self, pos) -> str | None: + """ + Return the URL under the given widget position, or None if there isn't one. + """ + cursor = self.cursorForPosition(pos) + block = cursor.block() + text = block.text() + if not text: + return None + + # Position of the cursor inside this block + pos_in_block = cursor.position() - block.position() + + # Same pattern as in MarkdownHighlighter + url_pattern = re.compile(r"(https?://[^\s<>()]+)") + for m in url_pattern.finditer(text): + start, end = m.span(1) + if start <= pos_in_block < end: + return m.group(1) + + return None + def keyPressEvent(self, event): """Handle special key events for markdown editing.""" @@ -622,6 +653,37 @@ class MarkdownEditor(QTextEdit): # Default handling super().keyPressEvent(event) + def mouseMoveEvent(self, event): + # Change cursor when hovering a link + url = self._url_at_pos(event.pos()) + if url: + self.viewport().setCursor(Qt.PointingHandCursor) + else: + self.viewport().setCursor(Qt.IBeamCursor) + + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + # Let QTextEdit handle caret/selection first + super().mouseReleaseEvent(event) + + if event.button() != Qt.LeftButton: + return + + # If the user dragged to select text, don't treat it as a click + if self.textCursor().hasSelection(): + return + + url_str = self._url_at_pos(event.pos()) + if not url_str: + return + + url = QUrl(url_str) + if not url.scheme(): + url.setScheme("https") + + QDesktopServices.openUrl(url) + def mousePressEvent(self, event): """Toggle a checkbox only when the click lands on its icon.""" if event.button() == Qt.LeftButton: diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 3576d1b..18dd446 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -91,6 +91,13 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.h3_format.setFontPointSize(14.0) self.h3_format.setFontWeight(QFont.Weight.Bold) + # Hyperlinks + self.link_format = QTextCharFormat() + link_color = pal.color(QPalette.Link) + self.link_format.setForeground(link_color) + self.link_format.setFontUnderline(True) + self.link_format.setAnchor(True) + # Markdown syntax (the markers themselves) - make invisible self.syntax_format = QTextCharFormat() # Make the markers invisible by setting font size to 0.1 points @@ -243,3 +250,15 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.setFormat(start, 1, self.syntax_format) self.setFormat(end - 1, 1, self.syntax_format) self.setFormat(content_start, content_end - content_start, self.code_format) + + # Hyperlinks + url_pattern = re.compile(r"(https?://[^\s<>()]+)") + for m in url_pattern.finditer(text): + start, end = m.span(1) + url = m.group(1) + + # Clone link format so we can attach a per-link href + fmt = QTextCharFormat(self.link_format) + fmt.setAnchorHref(url) + # Overlay link attributes on top of whatever formatting is already there + self._overlay_range(start, end - start, fmt)