Tags working

This commit is contained in:
Miguel Jacq 2025-11-14 14:54:04 +11:00
parent 3263788415
commit f6e10dccac
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
11 changed files with 1148 additions and 267 deletions

View file

@ -8,7 +8,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple, Iterable
from typing import List, Sequence, Tuple
from . import strings
@ -458,8 +458,9 @@ class DBManager:
"""
Replace the tag set for a page with the given names.
Creates new tags as needed (with auto colours).
Tags are case-insensitive - reuses existing tag if found with different case.
"""
# Normalise + dedupe
# Normalise + dedupe (case-insensitive)
clean_names = []
seen = set()
for name in tag_names:
@ -482,31 +483,44 @@ class DBManager:
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
return
# Ensure tag rows exist
# For each tag name, check if it exists with different casing
# If so, reuse that existing tag; otherwise create new
final_tag_names = []
for name in clean_names:
cur.execute(
"""
INSERT OR IGNORE INTO tags(name, color)
VALUES (?, ?);
""",
(name, self._default_tag_colour(name)),
)
# Look for existing tag (case-insensitive)
existing = cur.execute(
"SELECT name FROM tags WHERE LOWER(name) = LOWER(?);", (name,)
).fetchone()
# Lookup ids
placeholders = ",".join("?" for _ in clean_names)
if existing:
# Use the existing tag's exact name
final_tag_names.append(existing["name"])
else:
# Create new tag with the provided casing
cur.execute(
"""
INSERT OR IGNORE INTO tags(name, color)
VALUES (?, ?);
""",
(name, self._default_tag_colour(name)),
)
final_tag_names.append(name)
# Lookup ids for the final tag names
placeholders = ",".join("?" for _ in final_tag_names)
rows = cur.execute(
f"""
SELECT id, name
FROM tags
WHERE name IN ({placeholders});
""",
tuple(clean_names),
tuple(final_tag_names),
).fetchall()
ids_by_name = {r["name"]: r["id"] for r in rows}
# Reset page_tags for this page
cur.execute("DELETE FROM page_tags WHERE page_date=?;", (date_iso,))
for name in clean_names:
for name in final_tag_names:
tag_id = ids_by_name.get(name)
if tag_id is not None:
cur.execute(

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtWidgets import QLayout, QSizePolicy
from PySide6.QtWidgets import QLayout
class FlowLayout(QLayout):

View file

@ -112,15 +112,23 @@
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"manage_tags": "Manage tags on this page",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"tag_name": "Tag name",
"tag_color_hex": "Hex colour",
"color_hex": "Color",
"date": "Date",
"pick_color": "Pick colour",
"invalid_color_title": "Invalid colour",
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
"add": "Add",
"remove": "Remove",
"ok": "OK"
"ok": "OK",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages."
}

View file

@ -110,5 +110,25 @@
"toolbar_numbered_list": "Liste numérotée",
"toolbar_code_block": "Bloc de code",
"toolbar_heading": "Titre",
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases"
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases",
"tags": "Tags",
"manage_tags": "Gérer les tags",
"add_tag_placeholder": "Ajouter un tag et appuyez sur Entrée",
"tag_browser_title": "Navigateur de tags",
"tag_browser_instructions": "Cliquez sur un tag pour l'étendre et voir toutes les pages avec ce tag. Cliquez sur une date pour l'ouvrir. Sélectionnez un tag pour modifier son nom, changer sa couleur ou le supprimer globalement.",
"tag_name": "Nom du tag",
"tag_color_hex": "Couleur hexadécimale",
"color_hex": "Couleur",
"date": "Date",
"pick_color": "Choisir la couleur",
"invalid_color_title": "Couleur invalide",
"invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
"add": "Ajouter",
"remove": "Supprimer",
"ok": "OK",
"edit_tag_name": "Modifier le nom du tag",
"new_tag_name": "Nouveau nom du tag :",
"change_color": "Changer la couleur",
"delete_tag": "Supprimer le tag",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer le tag '{name}' ? Cela le supprimera de toutes les pages."
}

View file

@ -110,5 +110,25 @@
"toolbar_numbered_list": "Elenco numerato",
"toolbar_code_block": "Blocco di codice",
"toolbar_heading": "Titolo",
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo"
"toolbar_toggle_checkboxes": "Attiva/disattiva caselle di controllo",
"tags": "Tag",
"manage_tags": "Gestisci tag",
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"tag_name": "Nome del tag",
"tag_color_hex": "Colore esadecimale",
"color_hex": "Colore",
"date": "Data",
"pick_color": "Scegli colore",
"invalid_color_title": "Colore non valido",
"invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
"add": "Aggiungi",
"remove": "Rimuovi",
"ok": "OK",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",
"change_color": "Cambia colore",
"delete_tag": "Elimina tag",
"delete_tag_confirm": "Sei sicuro di voler eliminare il tag '{name}'? Questo lo rimuoverà da tutte le pagine."
}

View file

@ -57,7 +57,6 @@ 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
@ -96,6 +95,8 @@ class MainWindow(QMainWindow):
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
@ -181,11 +182,6 @@ 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)
@ -213,6 +209,10 @@ class MainWindow(QMainWindow):
act_backup.setShortcut("Ctrl+Shift+B")
act_backup.triggered.connect(self._backup)
file_menu.addAction(act_backup)
act_tags = QAction("&" + strings._("manage_tags"), self)
act_tags.setShortcut("Ctrl+T")
act_tags.triggered.connect(self.tags._open_manager)
file_menu.addAction(act_tags)
file_menu.addSeparator()
act_quit = QAction("&" + strings._("quit"), self)
act_quit.setShortcut("Ctrl+Q")
@ -509,6 +509,7 @@ class MainWindow(QMainWindow):
self.calendar.setSelectedDate(editor.current_date)
# update per-page tags for the active tab
date_iso = editor.current_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Reconnect toolbar to new active editor
@ -814,6 +815,10 @@ class MainWindow(QMainWindow):
if current_index >= 0:
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
# Update tags for the newly loaded page
date_iso = new_date.toString("yyyy-MM-dd")
self._update_tag_views_for_date(date_iso)
# Keep tabs sorted by date
self._reorder_tabs_by_date()
@ -1034,15 +1039,45 @@ class MainWindow(QMainWindow):
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
def _on_tag_added(self):
"""Called when a tag is added - trigger autosave for current page"""
# Use QTimer to defer the save slightly, avoiding re-entrancy issues
from PySide6.QtCore import QTimer
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name)
dlg.openDateRequested.connect(self._load_selected_date)
dlg.exec()
QTimer.singleShot(0, self._do_tag_save)
def _do_tag_save(self):
"""Actually perform the save after tag is added"""
if hasattr(self, "editor") and hasattr(self.editor, "current_date"):
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Get current editor content
text = self.editor.to_markdown()
# Save the content (or blank if page is empty)
# This ensures the page shows up in tag browser
self.db.save_new_version(date_iso, text, note="Tag added")
self._dirty = False
self._refresh_calendar_marks()
from datetime import datetime as _dt
self.statusBar().showMessage(
strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}",
2000,
)
def _on_tag_activated(self, tag_name_or_date: str):
# If it's a date (YYYY-MM-DD format), load it
if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2:
self._load_selected_date(tag_name_or_date)
else:
# It's a tag name, open the tag browser
from .tag_browser import TagBrowserDialog
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date)
dlg.openDateRequested.connect(self._load_selected_date)
dlg.exec()
# ----------- Settings handler ------------#
def _open_settings(self):

View file

@ -1,63 +0,0 @@
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)

View file

@ -1,11 +1,16 @@
# tag_browser.py
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTreeWidget,
QTreeWidgetItem,
QPushButton,
QLabel,
QColorDialog,
QMessageBox,
)
from .db import DBManager
@ -18,33 +23,86 @@ class TagBrowserDialog(QDialog):
def __init__(self, db: DBManager, parent=None, focus_tag: str | None = None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("tag_browser_title"))
self.setWindowTitle(
strings._("tag_browser_title") + " / " + strings._("manage_tags")
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(strings._("tag_browser_instructions"))
instructions.setWordWrap(True)
layout.addWidget(instructions)
self.tree = QTreeWidget()
self.tree.setHeaderLabels([strings._("tag"), strings._("date")])
self.tree.setHeaderLabels(
[strings._("tag"), strings._("color_hex"), strings._("date")]
)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 100)
self.tree.itemActivated.connect(self._on_item_activated)
self.tree.itemClicked.connect(self._on_item_clicked)
layout.addWidget(self.tree)
# Tag management buttons
btn_row = QHBoxLayout()
self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
self.edit_name_btn.clicked.connect(self._edit_tag_name)
self.edit_name_btn.setEnabled(False)
btn_row.addWidget(self.edit_name_btn)
self.change_color_btn = QPushButton(strings._("change_color"))
self.change_color_btn.clicked.connect(self._change_tag_color)
self.change_color_btn.setEnabled(False)
btn_row.addWidget(self.change_color_btn)
self.delete_btn = QPushButton(strings._("delete_tag"))
self.delete_btn.clicked.connect(self._delete_tag)
self.delete_btn.setEnabled(False)
btn_row.addWidget(self.delete_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
# Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self._populate(focus_tag)
def _populate(self, focus_tag: str | None):
self.tree.clear()
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)
# Create the tree item
root = QTreeWidgetItem([name, "", ""])
root.setData(
0,
Qt.ItemDataRole.UserRole,
{"type": "tag", "id": tag_id, "name": name, "color": color},
)
# Set background color for the second column to show the tag color
root.setBackground(1, QColor(color))
root.setText(1, color) # Also show the hex code
root.setTextAlignment(1, Qt.AlignCenter)
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)
child = QTreeWidgetItem(["", "", date_iso])
child.setData(
0, Qt.ItemDataRole.UserRole, {"type": "page", "date": date_iso}
)
root.addChild(child)
if focus_tag and name.lower() == focus_tag.lower():
@ -54,7 +112,94 @@ class TagBrowserDialog(QDialog):
self.tree.expandItem(focus_item)
self.tree.setCurrentItem(focus_item)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Enable/disable buttons based on selection"""
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "tag":
self.edit_name_btn.setEnabled(True)
self.change_color_btn.setEnabled(True)
self.delete_btn.setEnabled(True)
else:
self.edit_name_btn.setEnabled(False)
self.change_color_btn.setEnabled(False)
self.delete_btn.setEnabled(False)
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)
data = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(data, dict):
if data.get("type") == "page":
date_iso = data.get("date")
if date_iso:
self.openDateRequested.emit(date_iso)
self.accept()
def _edit_tag_name(self):
"""Edit the name of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
old_name = data["name"]
color = data["color"]
# Simple input dialog
from PySide6.QtWidgets import QInputDialog
new_name, ok = QInputDialog.getText(
self, strings._("edit_tag_name"), strings._("new_tag_name"), text=old_name
)
if ok and new_name and new_name != old_name:
self._db.update_tag(tag_id, new_name, color)
self._populate(None)
def _change_tag_color(self):
"""Change the color of the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
current_color = data["color"]
color = QColorDialog.getColor(QColor(current_color), self)
if color.isValid():
self._db.update_tag(tag_id, name, color.name())
self._populate(None)
def _delete_tag(self):
"""Delete the selected tag"""
item = self.tree.currentItem()
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(data, dict) or data.get("type") != "tag":
return
tag_id = data["id"]
name = data["name"]
# Confirm deletion
reply = QMessageBox.question(
self,
strings._("delete_tag"),
strings._("delete_tag_confirm").format(name=name),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_tag(tag_id)
self._populate(None)

View file

@ -1,135 +0,0 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QPushButton,
QColorDialog,
QMessageBox,
)
from . import strings
from .db import DBManager
class TagManagerDialog(QDialog):
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("manage_tags"))
layout = QVBoxLayout(self)
self.table = QTableWidget(0, 2, self)
self.table.setHorizontalHeaderLabels(
[strings._("tag_name"), strings._("tag_color_hex")]
)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table)
btn_row = QHBoxLayout()
self.add_btn = QPushButton(strings._("add"))
self.remove_btn = QPushButton(strings._("remove"))
self.color_btn = QPushButton(strings._("pick_color"))
btn_row.addWidget(self.add_btn)
btn_row.addWidget(self.remove_btn)
btn_row.addWidget(self.color_btn)
btn_row.addStretch(1)
layout.addLayout(btn_row)
action_row = QHBoxLayout()
ok_btn = QPushButton(strings._("ok"))
close_btn = QPushButton(strings._("close"))
ok_btn.clicked.connect(self.accept)
close_btn.clicked.connect(self.reject)
action_row.addStretch(1)
action_row.addWidget(ok_btn)
action_row.addWidget(close_btn)
layout.addLayout(action_row)
self.add_btn.clicked.connect(self._add_row)
self.remove_btn.clicked.connect(self._remove_selected)
self.color_btn.clicked.connect(self._pick_color)
self._load_tags()
def _load_tags(self) -> None:
self.table.setRowCount(0)
for tag_id, name, color in self._db.list_tags():
row = self.table.rowCount()
self.table.insertRow(row)
name_item = QTableWidgetItem(name)
name_item.setData(Qt.ItemDataRole.UserRole, tag_id)
self.table.setItem(row, 0, name_item)
color_item = QTableWidgetItem(color)
color_item.setBackground(Qt.GlobalColor.transparent)
self.table.setItem(row, 1, color_item)
def _add_row(self) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
name_item = QTableWidgetItem("")
name_item.setData(Qt.ItemDataRole.UserRole, 0) # 0 => new tag
self.table.setItem(row, 0, name_item)
self.table.setItem(row, 1, QTableWidgetItem("#CCCCCC"))
def _remove_selected(self) -> None:
row = self.table.currentRow()
if row < 0:
return
item = self.table.item(row, 0)
tag_id = item.data(Qt.ItemDataRole.UserRole)
if tag_id:
self._db.delete_tag(int(tag_id))
self.table.removeRow(row)
def _pick_color(self) -> None:
row = self.table.currentRow()
if row < 0:
return
item = self.table.item(row, 1)
current = item.text() or "#CCCCCC"
color = QColorDialog.getColor()
if color.isValid():
item.setText(color.name())
def accept(self) -> None:
# Persist all rows back to DB
for row in range(self.table.rowCount()):
name_item = self.table.item(row, 0)
color_item = self.table.item(row, 1)
if name_item is None or color_item is None:
continue
name = name_item.text().strip()
color = color_item.text().strip() or "#CCCCCC"
tag_id = int(name_item.data(Qt.ItemDataRole.UserRole) or 0)
if not name:
continue # ignore empty rows
if not color.startswith("#") or len(color) not in (4, 7):
QMessageBox.warning(
self,
strings._("invalid_color_title"),
strings._("invalid_color_message"),
)
return # keep dialog open
if tag_id == 0:
# new tag: just rely on set_tags_for_page/create, or you can
# insert here if you like. Easiest is to create via DBManager:
# use a dummy page or do a direct insert
self._db.set_tags_for_page("__dummy__", [name])
# then delete the dummy row; or instead provide a DBManager
# helper to create a tag explicitly.
else:
self._db.update_tag(tag_id, name, color)
super().accept()

View file

@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QSizePolicy,
QStyle,
QCompleter,
)
from . import strings
@ -25,7 +26,12 @@ class TagChip(QFrame):
clicked = Signal(str) # tag name
def __init__(
self, tag_id: int, name: str, color: str, parent: QWidget | None = None
self,
tag_id: int,
name: str,
color: str,
parent: QWidget | None = None,
show_remove: bool = True,
):
super().__init__(parent)
self._id = tag_id
@ -37,26 +43,26 @@ class TagChip(QFrame):
self.setFrameShadow(QFrame.Raised)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 0, 4, 0)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(4)
color_lbl = QLabel()
color_lbl.setFixedSize(10, 10)
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 3px;")
color_lbl.setStyleSheet(f"background-color: {color}; border-radius: 5px;")
layout.addWidget(color_lbl)
name_lbl = QLabel(name)
layout.addWidget(name_lbl)
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
if show_remove:
btn = QToolButton()
btn.setText("×")
btn.setAutoRaise(True)
btn.clicked.connect(lambda: self.removeRequested.emit(self._id))
layout.addWidget(btn)
self.setCursor(Qt.PointingHandCursor)
layout.addWidget(btn)
@property
def tag_id(self) -> int:
return self._id
@ -70,8 +76,12 @@ class TagChip(QFrame):
class PageTagsWidget(QFrame):
"""
Collapsible per-page tag editor shown in the left sidebar.
Now displays tag chips even when collapsed.
"""
tagActivated = Signal(str) # tag name
tagAdded = Signal() # emitted when a tag is added to trigger autosave
def __init__(self, db: DBManager, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
@ -103,21 +113,25 @@ class PageTagsWidget(QFrame):
header.addStretch(1)
header.addWidget(self.manage_btn)
# Body (chips + add line)
# Body (chips + add line - only visible when expanded)
self.body = QWidget()
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(0, 4, 0, 0)
self.body_layout.setSpacing(4)
# Simple horizontal layout for now; you can swap for a FlowLayout
self.chip_row = FlowLayout(self.body, hspacing=4, vspacing=4)
self.body_layout.addLayout(self.chip_row)
# Chips container
self.chip_container = QWidget()
self.chip_layout = FlowLayout(self.chip_container, hspacing=4, vspacing=4)
self.body_layout.addWidget(self.chip_container)
self.add_edit = QLineEdit()
self.add_edit.setPlaceholderText(strings._("add_tag_placeholder"))
self.add_edit.returnPressed.connect(self._on_add_tag)
self.body_layout.addWidget(self.add_edit)
# Setup autocomplete
self._setup_autocomplete()
self.body_layout.addWidget(self.add_edit)
self.body.setVisible(False)
main = QVBoxLayout(self)
@ -129,23 +143,33 @@ class PageTagsWidget(QFrame):
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
# Only reload tags if expanded
if self.toggle_btn.isChecked():
self._reload_tags()
else:
# Keep it cheap while collapsed; reload only when expanded
self._clear_chips()
self._clear_chips() # Clear chips when collapsed
self._setup_autocomplete() # Update autocomplete with all available tags
# ----- internals ---------------------------------------------------
def _setup_autocomplete(self) -> None:
"""Setup autocomplete for the tag input with all existing tags"""
all_tags = [name for _, name, _ in self._db.list_tags()]
completer = QCompleter(all_tags, self.add_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.add_edit.setCompleter(completer)
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked and self._current_date:
self._reload_tags()
if checked:
if self._current_date:
self._reload_tags()
self.add_edit.setFocus()
def _clear_chips(self) -> None:
while self.chip_row.count():
item = self.chip_row.takeAt(0)
while self.chip_layout.count():
item = self.chip_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
@ -158,26 +182,56 @@ class PageTagsWidget(QFrame):
self._clear_chips()
tags = self._db.get_tags_for_page(self._current_date)
for tag_id, name, color in tags:
chip = TagChip(tag_id, name, color, self)
# Always show remove button since chips only visible when expanded
chip = TagChip(tag_id, name, color, self, show_remove=True)
chip.removeRequested.connect(self._remove_tag)
chip.clicked.connect(self._on_chip_clicked)
self.chip_row.addWidget(chip)
self.chip_layout.addWidget(chip)
chip.show()
chip.adjustSize()
# Force complete layout recalculation
self.chip_layout.invalidate()
self.chip_layout.activate()
self.chip_container.updateGeometry()
self.updateGeometry()
# Process pending events to ensure layout is applied
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
def _on_add_tag(self) -> None:
if not self._current_date:
return
# If the completer popup is visible and user pressed Enter,
# the completer will handle it - don't process it again
if self.add_edit.completer() and self.add_edit.completer().popup().isVisible():
return
new_tag = self.add_edit.text().strip()
if not new_tag:
return
# Combine current tags + new one, then write back
# Get existing tags for current page
existing = [
name for _, name, _ in self._db.get_tags_for_page(self._current_date)
]
# Check for duplicates (case-insensitive)
if any(tag.lower() == new_tag.lower() for tag in existing):
self.add_edit.clear()
return
existing.append(new_tag)
self._db.set_tags_for_page(self._current_date, existing)
self.add_edit.clear()
self._reload_tags()
self._setup_autocomplete() # Update autocomplete list
# Signal that a tag was added so main window can trigger autosave
self.tagAdded.emit()
def _remove_tag(self, tag_id: int) -> None:
if not self._current_date:
@ -188,13 +242,15 @@ class PageTagsWidget(QFrame):
self._reload_tags()
def _open_manager(self) -> None:
from .tags_dialog import TagManagerDialog
from .tag_browser import TagBrowserDialog
dlg = TagManagerDialog(self._db, self)
dlg = TagBrowserDialog(self._db, self)
dlg.openDateRequested.connect(lambda date_iso: self.tagActivated.emit(date_iso))
if dlg.exec():
# Names/colours may have changed
# Reload tags after manager closes to pick up any changes
if self._current_date:
self._reload_tags()
self._setup_autocomplete()
def _on_chip_clicked(self, name: str) -> None:
self.tagActivated.emit(name)