More styling shenanigans, fix an export typo bug

This commit is contained in:
Miguel Jacq 2025-11-04 12:58:42 +11:00
parent 03b10ab692
commit f8e0a7f179
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
4 changed files with 213 additions and 50 deletions

View file

@ -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 # 0.1.8
* Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed * Fix saving the new key to the settings if the 'remember key' option was set and the DB was rekeyed

View file

@ -21,6 +21,7 @@ class Editor(QTextEdit):
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)') _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
_CODE_BG = QColor(245, 245, 245) _CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
_HEADING_SIZES = (24.0, 18.0, 14.0)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -40,6 +41,21 @@ class Editor(QTextEdit):
self.textChanged.connect(self._linkify_document) self.textChanged.connect(self._linkify_document)
self.viewport().setMouseTracking(True) 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): def _find_code_frame(self, cursor=None):
"""Return the nearest ancestor frame that's one of our code frames, else None.""" """Return the nearest ancestor frame that's one of our code frames, else None."""
if cursor is None: if cursor is None:
@ -139,6 +155,7 @@ class Editor(QTextEdit):
if key in (Qt.Key_Return, Qt.Key_Enter): if key in (Qt.Key_Return, Qt.Key_Enter):
c = self.textCursor() c = self.textCursor()
# If we're on an empty line inside a code frame, consume Enter and jump out # If we're on an empty line inside a code frame, consume Enter and jump out
if c.block().length() == 1: if c.block().length() == 1:
frame = self._find_code_frame(c) frame = self._find_code_frame(c)
@ -149,6 +166,13 @@ class Editor(QTextEdit):
super().insertPlainText("\n") # start a normal paragraph super().insertPlainText("\n") # start a normal paragraph
return 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 # otherwise default handling
return super().keyPressEvent(e) return super().keyPressEvent(e)
@ -158,28 +182,32 @@ class Editor(QTextEdit):
self.setCurrentCharFormat(nf) self.setCurrentCharFormat(nf)
def _break_anchor_for_next_char(self): def _break_anchor_for_next_char(self):
c = self.textCursor() """
fmt = c.charFormat() Ensure the *next* typed character is not part of a hyperlink.
if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0: Only strips link-specific attributes; leaves bold/italic/underline etc intact.
# clone, then strip just the link-specific bits so the next char is plain text """
nf = QTextCharFormat(fmt) # What we're about to type with
nf.setAnchor(False) ins_fmt = self.currentCharFormat()
nf.setFontUnderline(False) # What the cursor is sitting on
nf.clearForeground() cur_fmt = self.textCursor().charFormat()
try:
nf.setAnchorHref("") # Do nothing unless either side indicates we're in/propagating an anchor
except AttributeError: if not (ins_fmt.isAnchor() or cur_fmt.isAnchor()):
nf.setAnchorNames([]) return
self.setCurrentCharFormat(nf)
nf = QTextCharFormat(ins_fmt)
nf.setAnchor(False)
nf.setAnchorHref("")
self.setCurrentCharFormat(nf)
def merge_on_sel(self, fmt): 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() cursor = self.textCursor()
if not cursor.hasSelection(): if cursor.hasSelection():
cursor.select(cursor.SelectionType.WordUnderCursor) cursor.mergeCharFormat(fmt)
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt) self.mergeCurrentCharFormat(fmt)
@Slot() @Slot()
@ -265,15 +293,25 @@ class Editor(QTextEdit):
c.endEditBlock() c.endEditBlock()
@Slot(int) @Slot(int)
def apply_heading(self, size): def apply_heading(self, size: int):
fmt = QTextCharFormat() """
if size: Set heading point size for typing. If there's a selection, also apply bold
fmt.setFontWeight(QFont.Weight.Bold) to that selection (for H1..H3). "Normal" clears bold on the selection.
fmt.setFontPointSize(size) """
else: base_size = size if size else self.font().pointSizeF()
fmt.setFontWeight(QFont.Weight.Normal) c = self.textCursor()
fmt.setFontPointSize(self.font().pointSizeF())
self.merge_on_sel(fmt) # 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): def toggle_bullets(self):
c = self.textCursor() c = self.textCursor()

View file

@ -4,7 +4,16 @@ import os
import sys import sys
from pathlib import Path 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 ( from PySide6.QtGui import (
QAction, QAction,
QCursor, QCursor,
@ -12,6 +21,7 @@ from PySide6.QtGui import (
QFont, QFont,
QGuiApplication, QGuiApplication,
QTextCharFormat, QTextCharFormat,
QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCalendarWidget, QCalendarWidget,
@ -150,6 +160,9 @@ class MainWindow(QMainWindow):
self.toolBar.alignRequested.connect(self.editor.setAlignment) self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history) 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 = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
split.addWidget(self.editor) split.addWidget(self.editor)
@ -315,6 +328,61 @@ class MainWindow(QMainWindow):
pass pass
# --- UI handlers --------------------------------------------------------- # --- 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: def _current_date_iso(self) -> str:
d = self.calendar.selectedDate() d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
@ -518,9 +586,9 @@ class MainWindow(QMainWindow):
elif selected_filter.startswith("CSV"): elif selected_filter.startswith("CSV"):
self.db.export_csv(entries, filename) self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"): elif selected_filter.startswith("HTML"):
self.bd.export_html(entries, filename) self.db.export_html(entries, filename)
else: 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}") QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
except Exception as e: except Exception as e:

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Signal, Qt 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 from PySide6.QtWidgets import QToolBar
@ -25,54 +25,81 @@ class ToolBar(QToolBar):
self._apply_toolbar_styles() self._apply_toolbar_styles()
def _build_actions(self): 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.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested) 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.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested) 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.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested) 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.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested) 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.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested) self.actCode.triggered.connect(self.codeRequested)
# Headings # Headings
self.actH1 = QAction("Heading 1", self) self.actH1 = QAction("H1", self)
self.actH2 = QAction("Heading 2", self) self.actH1.setToolTip("Heading 1")
self.actH3 = QAction("Heading 3", self) self.actH1.setCheckable(True)
self.actNormal = QAction("Normal text", self)
self.actH1.setShortcut("Ctrl+1") 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.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.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.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)) self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
# Lists # 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.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) self.actNumbers.triggered.connect(self.numbersRequested)
# Alignment # Alignment
self.actAlignL = QAction("Align left", self) self.actAlignL = QAction("L", self)
self.actAlignC = QAction("Align center", self) self.actAlignL.setToolTip("Align Left")
self.actAlignR = QAction("Align right", self) self.actAlignL.setCheckable(True)
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft)) 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( self.actAlignC.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignHCenter) lambda: self.alignRequested.emit(Qt.AlignHCenter)
) )
self.actAlignR = QAction("R", self)
self.actAlignR.setToolTip("Align Right")
self.actAlignR.setCheckable(True)
self.actAlignR.triggered.connect( self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight) lambda: self.alignRequested.emit(Qt.AlignRight)
) )
@ -81,6 +108,28 @@ class ToolBar(QToolBar):
self.actHistory = QAction("History", self) self.actHistory = QAction("History", self)
self.actHistory.triggered.connect(self.historyRequested) 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.addActions(
[ [
self.actBold, self.actBold,
@ -106,7 +155,6 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actItalic, "I", italic=True) self._style_letter_button(self.actItalic, "I", italic=True)
self._style_letter_button(self.actUnderline, "U", underline=True) self._style_letter_button(self.actUnderline, "U", underline=True)
self._style_letter_button(self.actStrike, "S", strike=True) self._style_letter_button(self.actStrike, "S", strike=True)
# Monospace look for code; use a fixed font # Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._style_letter_button(self.actCode, "</>", custom_font=code_font) self._style_letter_button(self.actCode, "</>", custom_font=code_font)
@ -139,11 +187,13 @@ class ToolBar(QToolBar):
underline: bool = False, underline: bool = False,
strike: bool = False, strike: bool = False,
custom_font: QFont | None = None, custom_font: QFont | None = None,
tooltip: str | None = None,
): ):
btn = self.widgetForAction(action) btn = self.widgetForAction(action)
if not btn: if not btn:
return return
btn.setText(text) btn.setText(text)
f = custom_font if custom_font is not None else QFont(btn.font()) f = custom_font if custom_font is not None else QFont(btn.font())
if custom_font is None: if custom_font is None:
f.setBold(bold) f.setBold(bold)
@ -153,5 +203,6 @@ class ToolBar(QToolBar):
btn.setFont(f) btn.setFont(f)
# Keep accessibility/tooltip readable # Keep accessibility/tooltip readable
btn.setToolTip(action.text()) if tooltip:
btn.setAccessibleName(action.text()) btn.setToolTip(tooltip)
btn.setAccessibleName(tooltip)