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
* 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<>"\'<>]+)')
_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,27 +182,31 @@ 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)
"""
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.setFontUnderline(False)
nf.clearForeground()
try:
nf.setAnchorHref("")
except AttributeError:
nf.setAnchorNames([])
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)
if cursor.hasSelection():
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
@ -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()

View file

@ -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:

View file

@ -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)