diff --git a/bouquin/db.py b/bouquin/db.py index fdc6764..d040149 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -25,6 +25,7 @@ _TAG_COLORS = [ "#E0BAFF", # soft purple ] + @dataclass class DBConfig: path: Path @@ -198,7 +199,6 @@ class DBManager: ).fetchall() return [(r[0], r[1]) for r in rows] - def dates_with_content(self) -> list[str]: """ Find all entries and return the dates of them. @@ -560,6 +560,28 @@ class DBManager: cur.execute("DELETE FROM page_tags WHERE tag_id=?;", (tag_id,)) cur.execute("DELETE FROM tags WHERE id=?;", (tag_id,)) + def get_pages_for_tag(self, tag_name: str) -> list[Entry]: + """ + Return (date, content) for pages that have the given tag. + """ + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT p.date, v.content + FROM pages AS p + JOIN versions AS v + ON v.id = p.current_version_id + JOIN page_tags pt + ON pt.page_date = p.date + JOIN tags t + ON t.id = pt.tag_id + WHERE LOWER(t.name) = LOWER(?) + ORDER BY p.date DESC; + """, + (tag_name,), + ).fetchall() + return [(r[0], r[1]) for r in rows] + def close(self) -> None: if self.conn is not None: self.conn.close() diff --git a/bouquin/flow_layout.py b/bouquin/flow_layout.py new file mode 100644 index 0000000..937098d --- /dev/null +++ b/bouquin/flow_layout.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtWidgets import QLayout, QSizePolicy + + +class FlowLayout(QLayout): + def __init__( + self, parent=None, margin: int = 0, hspacing: int = 4, vspacing: int = 4 + ): + super().__init__(parent) + self._items = [] + self._hspace = hspacing + self._vspace = vspacing + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item): + self._items.append(item) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def count(self): + return len(self._items) + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width: int) -> int: + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect: QRect): + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self) -> QSize: + return self.minimumSize() + + def minimumSize(self) -> QSize: + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + left, top, right, bottom = self.getContentsMargins() + size += QSize(left + right, top + bottom) + return size + + def _do_layout(self, rect: QRect, test_only: bool) -> int: + x = rect.x() + y = rect.y() + line_height = 0 + + left, top, right, bottom = self.getContentsMargins() + effective_rect = rect.adjusted(+left, +top, -right, -bottom) + x = effective_rect.x() + y = effective_rect.y() + max_right = effective_rect.right() + + for item in self._items: + wid = item.widget() + if wid is None or not wid.isVisible(): + continue + space_x = self._hspace + space_y = self._vspace + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > max_right and line_height > 0: + # Wrap + x = effective_rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + bottom diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index c191f8f..74a45df 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -112,11 +112,15 @@ "toolbar_heading": "Heading", "toolbar_toggle_checkboxes": "Toggle checkboxes", "tags": "Tags", - "manage_tags": "Manage tags…", - "add_tag_placeholder": "Add tag and press Enter…", + "manage_tags": "Manage tags on this page", + "add_tag_placeholder": "Add a tag and press Enter", + "tag_browser_title": "Tag Browser", "tag_name": "Tag name", "tag_color_hex": "Hex colour", - "pick_color": "Pick colour…", + "pick_color": "Pick colour", "invalid_color_title": "Invalid colour", - "invalid_color_message": "Please enter a valid hex colour like #RRGGBB." + "invalid_color_message": "Please enter a valid hex colour like #RRGGBB.", + "add": "Add", + "remove": "Remove", + "ok": "OK" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index be8bb7f..6329286 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -57,6 +57,7 @@ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from . import strings from .tags_widget import PageTagsWidget +from .status_tags_widget import StatusBarTagsWidget from .toolbar import ToolBar from .theme import ThemeManager @@ -180,6 +181,11 @@ class MainWindow(QMainWindow): # FindBar will get the current editor dynamically via a callable self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self) self.statusBar().addPermanentWidget(self.findBar) + # status-bar tags widget + self.statusTags = StatusBarTagsWidget(self.db, self) + self.statusBar().addPermanentWidget(self.statusTags) + # Clicking a tag in the status bar will open tag pages (next section) + self.statusTags.tagActivated.connect(self._on_tag_activated) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) @@ -502,11 +508,8 @@ class MainWindow(QMainWindow): with QSignalBlocker(self.calendar): self.calendar.setSelectedDate(editor.current_date) - # update per-page tags for the active tab - if hasattr(self, "tags"): - date_iso = editor.current_date.toString("yyyy-MM-dd") - self.tags.set_current_date(date_iso) + self._update_tag_views_for_date(date_iso) # Reconnect toolbar to new active editor self._sync_toolbar() @@ -639,8 +642,7 @@ class MainWindow(QMainWindow): self._reorder_tabs_by_date() # sync tags - if hasattr(self, "tags"): - self.tags.set_current_date(date_iso) + self._update_tag_views_for_date(date_iso) def _load_date_into_editor(self, date: QDate, extra_data=False): """Load a specific date's content into a given editor.""" @@ -1005,6 +1007,20 @@ class MainWindow(QMainWindow): for path_str in paths: self.editor.insert_image_from_path(Path(path_str)) + # ----------- Tags handler ----------------# + def _update_tag_views_for_date(self, date_iso: str): + if hasattr(self, "tags"): + self.tags.set_current_date(date_iso) + if hasattr(self, "statusTags"): + self.statusTags.set_current_page(date_iso) + + def _on_tag_activated(self, tag_name: str): + from .tag_browser import TagBrowserDialog + + dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name) + dlg.openDateRequested.connect(self._load_selected_date) + dlg.exec() + # ----------- Settings handler ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) diff --git a/bouquin/status_tags_widget.py b/bouquin/status_tags_widget.py new file mode 100644 index 0000000..348c123 --- /dev/null +++ b/bouquin/status_tags_widget.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QWidget, + QHBoxLayout, + QLabel, + QSizePolicy, +) + +from .flow_layout import FlowLayout +from .db import DBManager +from .tags_widget import TagChip # reuse, or make a smaller variant if you prefer + + +class StatusBarTagsWidget(QWidget): + tagActivated = Signal(str) # tag name + + def __init__(self, db: DBManager, parent=None): + super().__init__(parent) + self._db = db + self._current_date_iso: str | None = None + + outer = QHBoxLayout(self) + outer.setContentsMargins(4, 0, 4, 0) + outer.setSpacing(4) + + label = QLabel("Tags:") + outer.addWidget(label) + + self.flow = FlowLayout(self, hspacing=2, vspacing=2) + outer.addLayout(self.flow) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + def set_current_page(self, date_iso: str): + self._current_date_iso = date_iso + self._reload() + + def _clear(self): + while self.flow.count(): + item = self.flow.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload(self): + self._clear() + if not self._current_date_iso: + return + + tags = self._db.get_tags_for_page(self._current_date_iso) + # Keep it small; maybe only first N tags: + MAX_TAGS = 6 + for i, (tid, name, color) in enumerate(tags): + if i >= MAX_TAGS: + more = QLabel("…") + self.flow.addWidget(more) + break + chip = TagChip(tid, name, color, self) + chip.clicked.connect(self.tagActivated) + # In status bar you might want a smaller style: adjust chip's stylesheet if needed. + self.flow.addWidget(chip) diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py new file mode 100644 index 0000000..3dc50e0 --- /dev/null +++ b/bouquin/tag_browser.py @@ -0,0 +1,60 @@ +# tag_browser.py +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QTreeWidget, + QTreeWidgetItem, + QPushButton, +) + +from .db import DBManager +from . import strings + + +class TagBrowserDialog(QDialog): + openDateRequested = Signal(str) + + def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None): + super().__init__(parent) + self._db = db + self.setWindowTitle(strings._("tag_browser_title")) + + layout = QVBoxLayout(self) + self.tree = QTreeWidget() + self.tree.setHeaderLabels([strings._("tag"), strings._("date")]) + self.tree.itemActivated.connect(self._on_item_activated) + layout.addWidget(self.tree) + + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + layout.addWidget(close_btn) + + self._populate(focus_tag) + + def _populate(self, focus_tag: str | None): + tags = self._db.list_tags() + focus_item = None + for tag_id, name, color in tags: + root = QTreeWidgetItem([name, ""]) + # coloured background or icon: + root.setData(0, Qt.ItemDataRole.UserRole, name) + self.tree.addTopLevelItem(root) + + pages = self._db.get_pages_for_tag(name) + for date_iso, _content in pages: + child = QTreeWidgetItem(["", date_iso]) + child.setData(0, Qt.ItemDataRole.UserRole, date_iso) + root.addChild(child) + + if focus_tag and name.lower() == focus_tag.lower(): + focus_item = root + + if focus_item: + self.tree.expandItem(focus_item) + self.tree.setCurrentItem(focus_item) + + def _on_item_activated(self, item: QTreeWidgetItem, column: int): + date_iso = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(date_iso, str) and date_iso: + self.openDateRequested.emit(date_iso) diff --git a/bouquin/tags_dialog.py b/bouquin/tags_dialog.py index 59c722a..21c8863 100644 --- a/bouquin/tags_dialog.py +++ b/bouquin/tags_dialog.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( from . import strings from .db import DBManager + class TagManagerDialog(QDialog): def __init__(self, db: DBManager, parent=None): super().__init__(parent) @@ -42,12 +43,12 @@ class TagManagerDialog(QDialog): action_row = QHBoxLayout() ok_btn = QPushButton(strings._("ok")) - cancel_btn = QPushButton(strings._("cancel")) + close_btn = QPushButton(strings._("close")) ok_btn.clicked.connect(self.accept) - cancel_btn.clicked.connect(self.reject) + close_btn.clicked.connect(self.reject) action_row.addStretch(1) action_row.addWidget(ok_btn) - action_row.addWidget(cancel_btn) + action_row.addWidget(close_btn) layout.addLayout(action_row) self.add_btn.clicked.connect(self._add_row) diff --git a/bouquin/tags_widget.py b/bouquin/tags_widget.py index 506ce88..e2a70d0 100644 --- a/bouquin/tags_widget.py +++ b/bouquin/tags_widget.py @@ -17,13 +17,20 @@ from PySide6.QtWidgets import ( from . import strings from .db import DBManager +from .flow_layout import FlowLayout + class TagChip(QFrame): removeRequested = Signal(int) # tag_id + clicked = Signal(str) # tag name - def __init__(self, tag_id: int, name: str, color: str, parent: QWidget | None = None): + def __init__( + self, tag_id: int, name: str, color: str, parent: QWidget | None = None + ): super().__init__(parent) self._id = tag_id + self._name = name + self.setObjectName("TagChip") self.setFrameShape(QFrame.StyledPanel) @@ -45,12 +52,20 @@ class TagChip(QFrame): btn.setText("×") btn.setAutoRaise(True) btn.clicked.connect(lambda: self.removeRequested.emit(self._id)) + + self.setCursor(Qt.PointingHandCursor) + layout.addWidget(btn) @property def tag_id(self) -> int: return self._id + def mouseReleaseEvent(self, ev): + if ev.button() == Qt.LeftButton: + self.clicked.emit(self._name) + super().mouseReleaseEvent(ev) + class PageTagsWidget(QFrame): """ @@ -75,7 +90,9 @@ class PageTagsWidget(QFrame): self.toggle_btn.clicked.connect(self._on_toggle) self.manage_btn = QToolButton() - self.manage_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView)) + self.manage_btn.setIcon( + self.style().standardIcon(QStyle.SP_FileDialogDetailedView) + ) self.manage_btn.setToolTip(strings._("manage_tags")) self.manage_btn.setAutoRaise(True) self.manage_btn.clicked.connect(self._open_manager) @@ -93,9 +110,7 @@ class PageTagsWidget(QFrame): self.body_layout.setSpacing(4) # Simple horizontal layout for now; you can swap for a FlowLayout - self.chip_row = QHBoxLayout() - self.chip_row.setContentsMargins(0, 0, 0, 0) - self.chip_row.setSpacing(4) + self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4) self.body_layout.addLayout(self.chip_row) self.add_edit = QLineEdit() @@ -145,8 +160,8 @@ class PageTagsWidget(QFrame): for tag_id, name, color in tags: chip = TagChip(tag_id, name, color, self) chip.removeRequested.connect(self._remove_tag) + chip.clicked.connect(self._on_chip_clicked) self.chip_row.addWidget(chip) - self.chip_row.addStretch(1) def _on_add_tag(self) -> None: if not self._current_date: @@ -156,7 +171,9 @@ class PageTagsWidget(QFrame): return # Combine current tags + new one, then write back - existing = [name for _, name, _ in self._db.get_tags_for_page(self._current_date)] + existing = [ + name for _, name, _ in self._db.get_tags_for_page(self._current_date) + ] existing.append(new_tag) self._db.set_tags_for_page(self._current_date, existing) self.add_edit.clear() @@ -179,3 +196,5 @@ class PageTagsWidget(QFrame): if self._current_date: self._reload_tags() + def _on_chip_clicked(self, name: str) -> None: + self.tagActivated.emit(name)