Support checkboxes, and TODO shortcut
This commit is contained in:
parent
7c3ec19748
commit
773afa5464
5 changed files with 323 additions and 79 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 block’s 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
127
bouquin/lock_overlay.py
Normal 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()
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue