diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0763e..a27c1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bouquin/editor.py b/bouquin/editor.py index f68d3c1..05ef128 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -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() diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py new file mode 100644 index 0000000..d019f3b --- /dev/null +++ b/bouquin/lock_overlay.py @@ -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() diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 7b29bbc..7c9d1d0 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -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).""" diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 5d5c451..78c737e 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -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,