Support checkboxes, and TODO shortcut

This commit is contained in:
Miguel Jacq 2025-11-06 13:43:52 +11:00
parent 7c3ec19748
commit 773afa5464
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 323 additions and 79 deletions

View file

@ -5,6 +5,7 @@
* Add ability to export to Markdown (and fix heading styles)
* Represent in the History diff pane when an image was the thing that changed
* Support theme choice in settings (light/dark/system)
* Add Checkboxes in the editor. Typing 'TODO' at the start of a line will auto-convert into a checkbox.
# 0.1.9

View file

@ -45,6 +45,11 @@ class Editor(QTextEdit):
_HEADING_SIZES = (24.0, 18.0, 14.0)
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
# --- Checkbox hack --- #
_CHECK_UNCHECKED = "\u2610" # ☐
_CHECK_CHECKED = "\u2611" # ☑
_CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space
_CHECKBOX_SCALE = 1.35
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -451,11 +456,52 @@ class Editor(QTextEdit):
self.viewport().setCursor(Qt.IBeamCursor)
super().mouseMoveEvent(e)
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier):
cur = self.cursorForPosition(e.pos())
b = cur.block()
state, pref = self._checkbox_info_for_block(b)
if state is not None:
col = cur.position() - b.position()
if col <= max(1, pref): # clicked on ☐/☑ (and the following space)
self._set_block_checkbox_state(b, not state)
return
return super().mousePressEvent(e)
def keyPressEvent(self, e):
key = e.key()
# Pre-insert: stop link/format bleed for “word boundary” keys
if key in (Qt.Key_Space, Qt.Key_Tab):
c = self.textCursor()
b = c.block()
pos_in_block = c.position() - b.position()
if (
pos_in_block >= 4
and b.text().startswith("TODO")
and b.text()[:pos_in_block] == "TODO"
and self._checkbox_info_for_block(b)[0] is None
):
tcur = QTextCursor(self.document())
tcur.setPosition(b.position()) # start of block
tcur.setPosition(
b.position() + 4, QTextCursor.KeepAnchor
) # select "TODO"
tcur.beginEditBlock()
tcur.removeSelectedText()
tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ "
tcur.endEditBlock()
# visuals: size bump
if hasattr(self, "_style_checkbox_glyph"):
self._style_checkbox_glyph(b)
# caret after the inserted prefix; swallow the key (we already added a space)
c.setPosition(b.position() + 2)
self.setTextCursor(c)
return
# not a TODO-at-start case
self._break_anchor_for_next_char()
return super().keyPressEvent(e)
@ -472,6 +518,26 @@ class Editor(QTextEdit):
super().insertPlainText("\n") # start a normal paragraph
return
# --- CHECKBOX handling: continue on Enter; "escape" on second Enter ---
b = c.block()
state, pref = self._checkbox_info_for_block(b)
if state is not None and not c.hasSelection():
text_after = b.text()[pref:].strip()
if c.atBlockEnd() and text_after == "":
# Empty checkbox item -> remove the prefix and insert a plain new line
cur = QTextCursor(self.document())
cur.setPosition(b.position())
cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
cur.removeSelectedText()
return super().keyPressEvent(e)
else:
# Normal continuation: new checkbox on the next line
super().keyPressEvent(e) # make the new block
super().insertPlainText(self._CHECK_UNCHECKED + " ")
if hasattr(self, "_style_checkbox_glyph"):
self._style_checkbox_glyph(self.textCursor().block())
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():
@ -520,6 +586,125 @@ class Editor(QTextEdit):
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
# ====== Checkbox core ======
def _base_point_size_for_block(self, block) -> float:
# Try the blocks char format, then editor font
sz = block.charFormat().fontPointSize()
if sz <= 0:
sz = self.fontPointSize()
if sz <= 0:
sz = self.font().pointSizeF() or 12.0
return float(sz)
def _style_checkbox_glyph(self, block):
"""Apply larger size (and optional symbol font) to the single ☐/☑ char."""
state, _ = self._checkbox_info_for_block(block)
if state is None:
return
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only
base = self._base_point_size_for_block(block)
fmt = QTextCharFormat()
fmt.setFontPointSize(base * self._CHECKBOX_SCALE)
# keep the glyph centered on the text baseline
fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle)
c.mergeCharFormat(fmt)
def _checkbox_info_for_block(self, block):
"""Return (state, prefix_len): state in {None, False, True}, prefix_len in chars."""
text = block.text()
m = self._CHECK_RX.match(text)
if not m:
return None, 0
ch = m.group(1)
state = True if ch == self._CHECK_CHECKED else False
return state, m.end()
def _set_block_checkbox_present(self, block, present: bool):
state, pref = self._checkbox_info_for_block(block)
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.beginEditBlock()
try:
if present and state is None:
c.insertText(self._CHECK_UNCHECKED + " ")
state = False
self._style_checkbox_glyph(block)
else:
if state is not None:
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
c.removeSelectedText()
state = None
finally:
c.endEditBlock()
return state
def _set_block_checkbox_state(self, block, checked: bool):
"""Switch ☐/☑ at the start of the block."""
state, pref = self._checkbox_info_for_block(block)
if state is None:
return
doc = self.document()
c = QTextCursor(doc)
c.setPosition(block.position())
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol
c.beginEditBlock()
try:
c.removeSelectedText()
c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED)
self._style_checkbox_glyph(block)
finally:
c.endEditBlock()
# Public API used by toolbar
def toggle_checkboxes(self):
"""
Toggle checkbox prefix on/off for the current block(s).
If all targeted blocks already have a checkbox, remove them; otherwise add.
"""
c = self.textCursor()
doc = self.document()
if c.hasSelection():
start = doc.findBlock(c.selectionStart())
end = doc.findBlock(c.selectionEnd() - 1)
else:
start = end = c.block()
# Decide intent: add or remove?
b = start
all_have = True
while True:
state, _ = self._checkbox_info_for_block(b)
if state is None:
all_have = False
break
if b == end:
break
b = b.next()
# Apply
b = start
while True:
self._set_block_checkbox_present(b, present=not all_have)
if b == end:
break
b = b.next()
def toggle_current_checkbox_state(self):
"""Tick/untick the current line if it starts with a checkbox."""
b = self.textCursor().block()
state, _ = self._checkbox_info_for_block(b)
if state is None:
return
self._set_block_checkbox_state(b, not state)
@Slot()
def apply_weight(self):
cur = self.currentCharFormat()

127
bouquin/lock_overlay.py Normal file
View file

@ -0,0 +1,127 @@
from __future__ import annotations
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
class LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
super().__init__(parent)
self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self._styling = False # <-- reentrancy guard
self._last_dark: bool | None = None
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel("Locked due to inactivity", self)
msg.setObjectName("lockLabel")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock", self)
self._btn.setObjectName("unlockButton")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
self._btn.setDefault(True)
self._btn.clicked.connect(on_unlock)
lay.addWidget(msg, 0, Qt.AlignCenter)
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self._apply_overlay_style()
self.hide()
def _is_dark(self, pal: QPalette) -> bool:
c = pal.color(QPalette.Window)
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
return luma < 0.5
def _apply_overlay_style(self):
if self._styling:
return
dark = self._is_dark(self.palette())
if dark == self._last_dark:
return
self._styling = True
try:
if dark:
link = self.palette().color(QPalette.Link)
accent_hex = link.name() # e.g. "#FFA500"
r, g, b = link.red(), link.green(), link.blue()
self.setStyleSheet(
f"""
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
)
else:
# (light mode unchanged)
self.setStyleSheet(
"""
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
)
self._last_dark = dark
finally:
self._styling = False
def changeEvent(self, ev):
super().changeEvent(ev)
# Only re-style on palette flips
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style()
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())
return False
def showEvent(self, e):
super().showEvent(e)
self._btn.setFocus()

View file

@ -32,10 +32,8 @@ from PySide6.QtWidgets import (
QCalendarWidget,
QDialog,
QFileDialog,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QVBoxLayout,
@ -46,6 +44,7 @@ from .db import DBManager
from .editor import Editor
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
@ -54,78 +53,6 @@ from .toolbar import ToolBar
from .theme import Theme, ThemeManager
class _LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
super().__init__(parent)
self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel("Locked due to inactivity")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
self._btn.setDefault(True)
self._btn.clicked.connect(on_unlock)
lay.addWidget(msg, 0, Qt.AlignCenter)
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self._apply_overlay_style()
self.hide() # start hidden
def _apply_overlay_style(self):
pal = self.palette()
bg = (
pal.window().color().darker(180)
if pal.color(QPalette.Window).value() < 128
else pal.window().color().lighter(110)
)
text = pal.windowText().color()
btn_bg = pal.button().color()
btn_fg = pal.buttonText().color()
border = pal.mid().color()
hover_bg = btn_bg.lighter(106) # +6%
press_bg = btn_bg.darker(106) # -6%
self.setStyleSheet(
f"""
#LockOverlay {{ background-color: {bg.name()}; }}
#LockOverlay QLabel {{ color: {text.name()}; font-size: 18px; }}
#LockOverlay QPushButton {{
background-color: {btn_bg.name()};
color: {btn_fg.name()};
padding: 6px 14px;
border: 1px solid {border.name()};
border-radius: 6px;
font-size: 14px;
}}
#LockOverlay QPushButton:hover {{ background-color: {hover_bg.name()}; }}
#LockOverlay QPushButton:pressed {{ background-color: {press_bg.name()}; }}
"""
)
# keep overlay sized with its parent
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())
return False
def showEvent(self, e):
super().showEvent(e)
self._btn.setFocus()
class MainWindow(QMainWindow):
def __init__(self, themes: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -182,6 +109,7 @@ class MainWindow(QMainWindow):
self.toolBar.headingRequested.connect(self.editor.apply_heading)
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history)
self.toolBar.insertImageRequested.connect(self._on_insert_image)
@ -207,8 +135,7 @@ class MainWindow(QMainWindow):
self._idle_timer.start()
# full-window overlay that sits on top of the central widget
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self._lock_overlay._apply_overlay_style()
self._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
@ -375,7 +302,6 @@ class MainWindow(QMainWindow):
pass
try:
# Apply to the search widget (if it's also a rich-text widget)
self.search.document().setDefaultStyleSheet(css)
except Exception:
pass
@ -562,7 +488,7 @@ class MainWindow(QMainWindow):
def _on_text_changed(self):
self._dirty = True
self._save_timer.start(10000) # autosave after idle
self._save_timer.start(5000) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""

View file

@ -14,6 +14,7 @@ class ToolBar(QToolBar):
headingRequested = Signal(int)
bulletsRequested = Signal()
numbersRequested = Signal()
checkboxesRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal()
insertImageRequested = Signal()
@ -86,6 +87,9 @@ class ToolBar(QToolBar):
self.actNumbers.setToolTip("Numbered list")
self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested)
self.actCheckboxes = QAction("", self)
self.actCheckboxes.setToolTip("Toggle checkboxes")
self.actCheckboxes.triggered.connect(self.checkboxesRequested)
# Images
self.actInsertImg = QAction("Image", self)
@ -150,6 +154,7 @@ class ToolBar(QToolBar):
self.actNormal,
self.actBullets,
self.actNumbers,
self.actCheckboxes,
self.actInsertImg,
self.actAlignL,
self.actAlignC,