diff --git a/CHANGELOG.md b/CHANGELOG.md index a50f185..26da09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Add weekday letters on left axis of Statistics page * Add the ability to choose the database path at startup * Add in-app bug report functionality + * Add ability to take screenshots in-app and insert them into the page # 0.3.1 diff --git a/README.md b/README.md index 2df84cf..5610c12 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ report from within the app. * All changes are version controlled, with ability to view/diff versions and revert * Text is Markdown with basic styling * Tabs are supported - right-click on a date from the calendar to open it in a new tab. - * Images are supported + * Images are supported, as is the ability to take a screenshot and have it insert into the page automatically. * Search all pages, or find text on page (Ctrl+F) * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours * Automatic periodic saving (or explicitly save) diff --git a/bouquin/bug_report_dialog.py b/bouquin/bug_report_dialog.py index 6285c77..59c8fcc 100644 --- a/bouquin/bug_report_dialog.py +++ b/bouquin/bug_report_dialog.py @@ -1,8 +1,6 @@ from __future__ import annotations import importlib.metadata -from pathlib import Path - import requests from PySide6.QtWidgets import ( @@ -32,8 +30,6 @@ class BugReportDialog(QDialog): super().__init__(parent) self.setWindowTitle(strings._("report_a_bug")) - self._attachment_path: Path | None = None - layout = QVBoxLayout(self) header = QLabel(strings._("bug_report_explanation")) @@ -48,9 +44,7 @@ class BugReportDialog(QDialog): # Buttons: Cancel / Send button_box = QDialogButtonBox(QDialogButtonBox.Cancel) - self.send_button = button_box.addButton( - strings._("send"), QDialogButtonBox.AcceptRole - ) + button_box.addButton(strings._("send"), QDialogButtonBox.AcceptRole) button_box.accepted.connect(self._send) button_box.rejected.connect(self.reject) layout.addWidget(button_box) diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 6cd9270..de72186 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -154,5 +154,9 @@ "bug_report_empty": "Please enter some details about the bug before sending.", "bug_report_send_failed": "Could not send bug report.", "bug_report_sent_ok": "Bug report sent. Thank you!", - "send": "Send" + "send": "Send", + "screenshot": "Take screenshot", + "screenshot_could_not_save": "Could not save screenshot", + "screenshot_get_ready": "You will have five seconds to position your screen before a preview is taken.\n\nYou will be able to click and drag to select a specific region of the preview.", + "screenshot_click_and_drag": "Click and drag to select a region or press enter to accept the whole region" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index da2339d..a411175 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -978,6 +978,7 @@ class MainWindow(QMainWindow): tb.historyRequested.connect(self._open_history) tb.insertImageRequested.connect(self._on_insert_image) + tb.insertScreenshotRequested.connect(self._start_screenshot) self._toolbar_bound = True @@ -1051,6 +1052,21 @@ class MainWindow(QMainWindow): for path_str in paths: self.editor.insert_image_from_path(Path(path_str)) + # ----------- Screenshot handler ------------# + def _start_screenshot(self): + ready = QMessageBox.information( + self, + strings._("screenshot"), + strings._("screenshot_get_ready"), + QMessageBox.Ok, + ) + if ready == QMessageBox.Ok: + QGuiApplication.processEvents() + QTimer.singleShot(5000, self._do_screenshot_capture) + + def _do_screenshot_capture(self): + self.editor.take_screenshot() + # ----------- Tags handler ----------------# def _update_tag_views_for_date(self, date_iso: str): if hasattr(self, "tags"): diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 3a33363..d73200c 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -16,9 +16,10 @@ from PySide6.QtGui import ( QTextImageFormat, QDesktopServices, ) -from PySide6.QtCore import Qt, QRect, QTimer, QUrl +from PySide6.QtCore import Qt, QRect, QTimer, QUrl, QStandardPaths from PySide6.QtWidgets import QTextEdit +from .screenshot import ScreenshotMarkdownInserter from .theme import ThemeManager from .markdown_highlighter import MarkdownHighlighter @@ -1064,3 +1065,8 @@ class MarkdownEditor(QTextEdit): cursor = self.textCursor() cursor.insertImage(img_format) cursor.insertText("\n") # Add newline after image + + def take_screenshot(self): + images_dir = QStandardPaths.writableLocation(QStandardPaths.PicturesLocation) + self._screenshot_helper = ScreenshotMarkdownInserter(self, images_dir, self) + self._screenshot_helper.capture_and_insert() diff --git a/bouquin/screenshot.py b/bouquin/screenshot.py new file mode 100644 index 0000000..0a71e7f --- /dev/null +++ b/bouquin/screenshot.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt, QRect, QPoint, Signal, QDateTime, QObject +from PySide6.QtGui import QGuiApplication, QPainter, QColor, QCursor, QPixmap +from PySide6.QtWidgets import QWidget, QRubberBand, QMessageBox + +from . import strings + + +class ScreenRegionGrabber(QWidget): + regionCaptured = Signal(QPixmap) + + def __init__(self, screenshot_pixmap: QPixmap, parent=None): + super().__init__(parent) + + self._screen_pixmap = screenshot_pixmap + self._selection_rect = QRect() + self._origin = QPoint() + + self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool) + self.setWindowState(Qt.WindowFullScreen) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setCursor(Qt.CrossCursor) + + self._rubber_band = QRubberBand(QRubberBand.Rectangle, self) + + def paintEvent(self, event): + painter = QPainter(self) + painter.drawPixmap(self.rect(), self._screen_pixmap) + + # Dim everything + painter.fillRect(self.rect(), QColor(0, 0, 0, 120)) + + # Punch a clear hole for the selection, if there is one + if self._selection_rect.isValid(): + painter.setCompositionMode(QPainter.CompositionMode_Clear) + painter.fillRect(self._selection_rect, Qt.transparent) + painter.setCompositionMode(QPainter.CompositionMode_SourceOver) + else: + # Placeholder text before first click + painter.setPen(QColor(255, 255, 255, 220)) + painter.drawText( + self.rect(), + Qt.AlignCenter, + strings._("screenshot_click_and_drag"), + ) + + painter.end() + + def mousePressEvent(self, event): + if event.button() != Qt.LeftButton: + return + self._origin = event.pos() + self._selection_rect = QRect(self._origin, self._origin) + self._rubber_band.setGeometry(self._selection_rect) + self._rubber_band.show() + + def mouseMoveEvent(self, event): + if not self._rubber_band.isVisible(): + return + self._selection_rect = QRect(self._origin, event.pos()).normalized() + self._rubber_band.setGeometry(self._selection_rect) + self.update() + + def mouseReleaseEvent(self, event): + if event.button() != Qt.LeftButton: + return + if not self._rubber_band.isVisible(): + return + + self._rubber_band.hide() + rect = self._selection_rect.intersected(self._screen_pixmap.rect()) + if rect.isValid(): + cropped = self._screen_pixmap.copy(rect) + self.regionCaptured.emit(cropped) + self.close() + + def keyPressEvent(self, event): + key = event.key() + + # Enter / Return → accept full screen + if key in (Qt.Key_Return, Qt.Key_Enter): + if self._screen_pixmap is not None and not self._screen_pixmap.isNull(): + self.regionCaptured.emit(self._screen_pixmap) + self.close() + return + + # Esc → cancel (no screenshot) + if key == Qt.Key_Escape: + self.close() + return + + # Fallback to default behaviour + super().keyPressEvent(event) + + +class ScreenshotMarkdownInserter(QObject): + """ + Helper that captures a region of the screen, saves it to `images_dir`, + and inserts a Markdown image reference into the MarkdownEditor. + """ + + def __init__(self, editor, images_dir: Path, parent=None): + super().__init__(parent) + self._editor = editor + self._images_dir = Path(images_dir) + self._grabber: ScreenRegionGrabber | None = None + + def capture_and_insert(self): + """ + Starts the screen-region selection overlay. When the user finishes, + the screenshot is saved and the Markdown is inserted in the editor. + """ + screen = QGuiApplication.screenAt(QCursor.pos()) + if screen is None: + screen = QGuiApplication.primaryScreen() + + pixmap = screen.grabWindow(0) + self._grabber = ScreenRegionGrabber(pixmap) + self._grabber.regionCaptured.connect(self._on_region_captured) + self._grabber.show() + + # ------------------------------------------------------------------ internals + + def _on_region_captured(self, pixmap): + if pixmap is None or pixmap.isNull(): + return + + # Ensure output directory exists + self._images_dir.mkdir(parents=True, exist_ok=True) + + timestamp = QDateTime.currentDateTime().toString("yyyyMMdd_HHmmsszzz") + filename = f"bouquin_screenshot_{timestamp}.png" + full_path = self._images_dir / filename + + if not pixmap.save(str(full_path), "PNG"): + QMessageBox.critical( + self, + strings._("screenshot"), + strings._("screenshot_could_not_save"), + ) + return + + self._insert_markdown_image(full_path) + + def _insert_markdown_image(self, path: Path): + """ + Insert image into the MarkdownEditor. + """ + if hasattr(self._editor, "insert_image_from_path"): + self._editor.insert_image_from_path(path) + return + + rel = path.name + markdown = f"![screenshot]({rel})" + + if hasattr(self._editor, "textCursor"): + cursor = self._editor.textCursor() + cursor.insertText(markdown) + self._editor.setTextCursor(cursor) + else: + self._editor.insertPlainText(markdown) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 89999b8..bcc59de 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -18,6 +18,7 @@ class ToolBar(QToolBar): checkboxesRequested = Signal() historyRequested = Signal() insertImageRequested = Signal() + insertScreenshotRequested = Signal() def __init__(self, parent=None): super().__init__(strings._("toolbar_format"), parent) @@ -81,16 +82,21 @@ class ToolBar(QToolBar): self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) - self.actCheckboxes = QAction("☐", self) + self.actCheckboxes = QAction("☑", self) self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images - self.actInsertImg = QAction(strings._("images"), self) + self.actInsertImg = QAction("🌄", self) self.actInsertImg.setToolTip(strings._("insert_images")) self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.triggered.connect(self.insertImageRequested) + self.actScreenshot = QAction("📸", self) + self.actScreenshot.setToolTip(strings._("screenshot")) + self.actScreenshot.setShortcut("Ctrl+Shift+O") + self.actScreenshot.triggered.connect(self.insertScreenshotRequested) + # History button self.actHistory = QAction(strings._("history"), self) self.actHistory.triggered.connect(self.historyRequested) @@ -130,6 +136,7 @@ class ToolBar(QToolBar): self.actNumbers, self.actCheckboxes, self.actInsertImg, + self.actScreenshot, self.actHistory, ] )