diff --git a/CHANGELOG.md b/CHANGELOG.md index f04325b..49634a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.1.9 + + * More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be + possible to set both H1 and H2 at once) + * Fix small bug in export of HTML or arbitrary extension + # 0.1.8 * Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed diff --git a/bouquin/editor.py b/bouquin/editor.py index 6efbb7a..afcd7e4 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -21,6 +21,7 @@ class Editor(QTextEdit): _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') _CODE_BG = QColor(245, 245, 245) _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames + _HEADING_SIZES = (24.0, 18.0, 14.0) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -40,6 +41,21 @@ class Editor(QTextEdit): self.textChanged.connect(self._linkify_document) self.viewport().setMouseTracking(True) + def _approx(self, a: float, b: float, eps: float = 0.5) -> bool: + return abs(float(a) - float(b)) <= eps + + def _is_heading_typing(self) -> bool: + """Is the current *insertion* format using a heading size?""" + s = self.currentCharFormat().fontPointSize() or self.font().pointSizeF() + return any(self._approx(s, h) for h in self._HEADING_SIZES) + + def _apply_normal_typing(self): + """Switch the *insertion* format to Normal (default size, normal weight).""" + nf = QTextCharFormat() + nf.setFontPointSize(self.font().pointSizeF()) + nf.setFontWeight(QFont.Weight.Normal) + self.mergeCurrentCharFormat(nf) + def _find_code_frame(self, cursor=None): """Return the nearest ancestor frame that's one of our code frames, else None.""" if cursor is None: @@ -139,6 +155,7 @@ class Editor(QTextEdit): if key in (Qt.Key_Return, Qt.Key_Enter): c = self.textCursor() + # If we're on an empty line inside a code frame, consume Enter and jump out if c.block().length() == 1: frame = self._find_code_frame(c) @@ -149,6 +166,13 @@ class Editor(QTextEdit): super().insertPlainText("\n") # start a normal paragraph return + # Follow-on style: if we typed a heading and press Enter at end of block, + # new paragraph should revert to Normal. + if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing(): + super().keyPressEvent(e) # insert the new paragraph + self._apply_normal_typing() # make the *new* paragraph Normal for typing + return + # otherwise default handling return super().keyPressEvent(e) @@ -158,28 +182,32 @@ class Editor(QTextEdit): self.setCurrentCharFormat(nf) def _break_anchor_for_next_char(self): - c = self.textCursor() - fmt = c.charFormat() - if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0: - # clone, then strip just the link-specific bits so the next char is plain text - nf = QTextCharFormat(fmt) - nf.setAnchor(False) - nf.setFontUnderline(False) - nf.clearForeground() - try: - nf.setAnchorHref("") - except AttributeError: - nf.setAnchorNames([]) - self.setCurrentCharFormat(nf) + """ + Ensure the *next* typed character is not part of a hyperlink. + Only strips link-specific attributes; leaves bold/italic/underline etc intact. + """ + # What we're about to type with + ins_fmt = self.currentCharFormat() + # What the cursor is sitting on + cur_fmt = self.textCursor().charFormat() + + # Do nothing unless either side indicates we're in/propagating an anchor + if not (ins_fmt.isAnchor() or cur_fmt.isAnchor()): + return + + nf = QTextCharFormat(ins_fmt) + nf.setAnchor(False) + nf.setAnchorHref("") + + self.setCurrentCharFormat(nf) def merge_on_sel(self, fmt): """ - Sets the styling on the selected characters. + Sets the styling on the selected characters or the insertion position. """ cursor = self.textCursor() - if not cursor.hasSelection(): - cursor.select(cursor.SelectionType.WordUnderCursor) - cursor.mergeCharFormat(fmt) + if cursor.hasSelection(): + cursor.mergeCharFormat(fmt) self.mergeCurrentCharFormat(fmt) @Slot() @@ -265,15 +293,25 @@ class Editor(QTextEdit): c.endEditBlock() @Slot(int) - def apply_heading(self, size): - fmt = QTextCharFormat() - if size: - fmt.setFontWeight(QFont.Weight.Bold) - fmt.setFontPointSize(size) - else: - fmt.setFontWeight(QFont.Weight.Normal) - fmt.setFontPointSize(self.font().pointSizeF()) - self.merge_on_sel(fmt) + def apply_heading(self, size: int): + """ + Set heading point size for typing. If there's a selection, also apply bold + to that selection (for H1..H3). "Normal" clears bold on the selection. + """ + base_size = size if size else self.font().pointSizeF() + c = self.textCursor() + + # Update the typing (insertion) format to be size only, but don't represent + # it as if the Bold style has been toggled on + ins = QTextCharFormat() + ins.setFontPointSize(base_size) + self.mergeCurrentCharFormat(ins) + + # If user selected text, style that text visually as a heading + if c.hasSelection(): + sel = QTextCharFormat(ins) + sel.setFontWeight(QFont.Weight.Bold if size else QFont.Weight.Normal) + c.mergeCharFormat(sel) def toggle_bullets(self): c = self.textCursor() diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 7d2fbfc..c73b647 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -4,7 +4,16 @@ import os import sys from pathlib import Path -from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent +from PySide6.QtCore import ( + QDate, + QTimer, + Qt, + QSettings, + Slot, + QUrl, + QEvent, + QSignalBlocker, +) from PySide6.QtGui import ( QAction, QCursor, @@ -12,6 +21,7 @@ from PySide6.QtGui import ( QFont, QGuiApplication, QTextCharFormat, + QTextListFormat, ) from PySide6.QtWidgets import ( QCalendarWidget, @@ -150,6 +160,9 @@ class MainWindow(QMainWindow): self.toolBar.alignRequested.connect(self.editor.setAlignment) self.toolBar.historyRequested.connect(self._open_history) + self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) + self.editor.cursorPositionChanged.connect(self._sync_toolbar) + split = QSplitter() split.addWidget(left_panel) split.addWidget(self.editor) @@ -315,6 +328,61 @@ class MainWindow(QMainWindow): pass # --- UI handlers --------------------------------------------------------- + + def _sync_toolbar(self): + fmt = self.editor.currentCharFormat() + c = self.editor.textCursor() + bf = c.blockFormat() + + # Block signals so setChecked() doesn't re-trigger actions + blocker1 = QSignalBlocker(self.toolBar.actBold) + blocker2 = QSignalBlocker(self.toolBar.actItalic) + blocker3 = QSignalBlocker(self.toolBar.actUnderline) + blocker4 = QSignalBlocker(self.toolBar.actStrike) + + self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) + self.toolBar.actItalic.setChecked(fmt.fontItalic()) + self.toolBar.actUnderline.setChecked(fmt.fontUnderline()) + self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) + + # Headings: decide which to check by current point size + def _approx(a, b, eps=0.5): # small float tolerance + return abs(float(a) - float(b)) <= eps + + cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF() + + bH1 = _approx(cur_size, 24) + bH2 = _approx(cur_size, 18) + bH3 = _approx(cur_size, 14) + + b1 = QSignalBlocker(self.toolBar.actH1) + b2 = QSignalBlocker(self.toolBar.actH2) + b3 = QSignalBlocker(self.toolBar.actH3) + bN = QSignalBlocker(self.toolBar.actNormal) + + self.toolBar.actH1.setChecked(bH1) + self.toolBar.actH2.setChecked(bH2) + self.toolBar.actH3.setChecked(bH3) + self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3)) + + # Lists + lst = c.currentList() + bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc + numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal + QSignalBlocker(self.toolBar.actBullets) + QSignalBlocker(self.toolBar.actNumbers) + self.toolBar.actBullets.setChecked(bool(bullets_on)) + self.toolBar.actNumbers.setChecked(bool(numbers_on)) + + # Alignment + align = bf.alignment() & Qt.AlignHorizontal_Mask + QSignalBlocker(self.toolBar.actAlignL) + self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft) + QSignalBlocker(self.toolBar.actAlignC) + self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter) + QSignalBlocker(self.toolBar.actAlignR) + self.toolBar.actAlignR.setChecked(align == Qt.AlignRight) + def _current_date_iso(self) -> str: d = self.calendar.selectedDate() return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" @@ -518,9 +586,9 @@ class MainWindow(QMainWindow): elif selected_filter.startswith("CSV"): self.db.export_csv(entries, filename) elif selected_filter.startswith("HTML"): - self.bd.export_html(entries, filename) + self.db.export_html(entries, filename) else: - self.bd.export_by_extension(entries, filename) + self.db.export_by_extension(entries, filename) QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}") except Exception as e: diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 241e15c..2924471 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase +from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup from PySide6.QtWidgets import QToolBar @@ -25,54 +25,81 @@ class ToolBar(QToolBar): self._apply_toolbar_styles() def _build_actions(self): - self.actBold = QAction("Bold", self) + self.actBold = QAction("B", self) + self.actBold.setToolTip("Bold") + self.actBold.setCheckable(True) self.actBold.setShortcut(QKeySequence.Bold) self.actBold.triggered.connect(self.boldRequested) - self.actItalic = QAction("Italic", self) + self.actItalic = QAction("I", self) + self.actItalic.setToolTip("Italic") + self.actItalic.setCheckable(True) self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.triggered.connect(self.italicRequested) - self.actUnderline = QAction("Underline", self) + self.actUnderline = QAction("U", self) + self.actUnderline.setToolTip("Underline") + self.actUnderline.setCheckable(True) self.actUnderline.setShortcut(QKeySequence.Underline) self.actUnderline.triggered.connect(self.underlineRequested) - self.actStrike = QAction("Strikethrough", self) + self.actStrike = QAction("S", self) + self.actStrike.setToolTip("Strikethrough") + self.actStrike.setCheckable(True) self.actStrike.setShortcut("Ctrl+-") self.actStrike.triggered.connect(self.strikeRequested) - self.actCode = QAction("Inline code", self) + self.actCode = QAction("", self) + self.actCode.setToolTip("Code block") self.actCode.setShortcut("Ctrl+`") self.actCode.triggered.connect(self.codeRequested) # Headings - self.actH1 = QAction("Heading 1", self) - self.actH2 = QAction("Heading 2", self) - self.actH3 = QAction("Heading 3", self) - self.actNormal = QAction("Normal text", self) + self.actH1 = QAction("H1", self) + self.actH1.setToolTip("Heading 1") + self.actH1.setCheckable(True) self.actH1.setShortcut("Ctrl+1") - self.actH2.setShortcut("Ctrl+2") - self.actH3.setShortcut("Ctrl+3") - self.actNormal.setShortcut("Ctrl+O") self.actH1.triggered.connect(lambda: self.headingRequested.emit(24)) + self.actH2 = QAction("H2", self) + self.actH2.setToolTip("Heading 2") + self.actH2.setCheckable(True) + self.actH2.setShortcut("Ctrl+2") self.actH2.triggered.connect(lambda: self.headingRequested.emit(18)) + self.actH3 = QAction("H3", self) + self.actH3.setToolTip("Heading 3") + self.actH3.setCheckable(True) + self.actH3.setShortcut("Ctrl+3") self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) + self.actNormal = QAction("N", self) + self.actNormal.setToolTip("Normal paragraph text") + self.actNormal.setCheckable(True) + self.actNormal.setShortcut("Ctrl+O") self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) # Lists - self.actBullets = QAction("Bulleted list", self) + self.actBullets = QAction("•", self) + self.actBullets.setToolTip("Bulleted list") + self.actBullets.setCheckable(True) self.actBullets.triggered.connect(self.bulletsRequested) - self.actNumbers = QAction("Numbered list", self) + self.actNumbers = QAction("1.", self) + self.actNumbers.setToolTip("Numbered list") + self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) # Alignment - self.actAlignL = QAction("Align left", self) - self.actAlignC = QAction("Align center", self) - self.actAlignR = QAction("Align right", self) + self.actAlignL = QAction("L", self) + self.actAlignL.setToolTip("Align Left") + self.actAlignL.setCheckable(True) self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft)) + self.actAlignC = QAction("C", self) + self.actAlignC.setToolTip("Align Center") + self.actAlignC.setCheckable(True) self.actAlignC.triggered.connect( lambda: self.alignRequested.emit(Qt.AlignHCenter) ) + self.actAlignR = QAction("R", self) + self.actAlignR.setToolTip("Align Right") + self.actAlignR.setCheckable(True) self.actAlignR.triggered.connect( lambda: self.alignRequested.emit(Qt.AlignRight) ) @@ -81,6 +108,28 @@ class ToolBar(QToolBar): self.actHistory = QAction("History", self) self.actHistory.triggered.connect(self.historyRequested) + # Set exclusive buttons in QActionGroups + self.grpHeadings = QActionGroup(self) + self.grpHeadings.setExclusive(True) + for a in ( + self.actBold, + self.actItalic, + self.actUnderline, + self.actStrike, + self.actH1, + self.actH2, + self.actH3, + self.actNormal, + ): + a.setCheckable(True) + a.setActionGroup(self.grpHeadings) + + self.grpAlign = QActionGroup(self) + self.grpAlign.setExclusive(True) + for a in (self.actAlignL, self.actAlignC, self.actAlignR): + a.setActionGroup(self.grpAlign) + + # Add actions self.addActions( [ self.actBold, @@ -106,7 +155,6 @@ class ToolBar(QToolBar): self._style_letter_button(self.actItalic, "I", italic=True) self._style_letter_button(self.actUnderline, "U", underline=True) self._style_letter_button(self.actStrike, "S", strike=True) - # Monospace look for code; use a fixed font code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) self._style_letter_button(self.actCode, "", custom_font=code_font) @@ -139,11 +187,13 @@ class ToolBar(QToolBar): underline: bool = False, strike: bool = False, custom_font: QFont | None = None, + tooltip: str | None = None, ): btn = self.widgetForAction(action) if not btn: return btn.setText(text) + f = custom_font if custom_font is not None else QFont(btn.font()) if custom_font is None: f.setBold(bold) @@ -153,5 +203,6 @@ class ToolBar(QToolBar): btn.setFont(f) # Keep accessibility/tooltip readable - btn.setToolTip(action.text()) - btn.setAccessibleName(action.text()) + if tooltip: + btn.setToolTip(tooltip) + btn.setAccessibleName(tooltip)