diff --git a/CHANGELOG.md b/CHANGELOG.md
index 203a3f2..d2024eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+# 0.2.0
+
+ * Switch back to Markdown editor
+
# 0.1.12.1
* Fix newline after URL keeps URL style formatting
diff --git a/README.md b/README.md
index a70013d..2cc03ef 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ There is deliberately no network connectivity or syncing intended.
* Encryption key is prompted for and never stored, unless user chooses to via Settings
* Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert
- * Text is HTML with basic styling
+ * Text is Markdown with basic styling
* Images are supported
* Search
* Automatic periodic saving (or explicitly save)
diff --git a/bouquin/db.py b/bouquin/db.py
index b6c937b..4e1fbf8 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -6,7 +6,6 @@ import json
import os
from dataclasses import dataclass
-from markdownify import markdownify as md
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple
@@ -401,25 +400,13 @@ class DBManager:
Export to HTML, similar to export_html, but then convert to Markdown
using markdownify, and finally save to file.
"""
- parts = [
- "",
- '',
- "
",
- f"{html.escape(title)}
",
- ]
+ parts = []
for d, c in entries:
- parts.append(
- f""
- )
- parts.append("")
-
- # Convert html to markdown
- md_items = []
- for item in parts:
- md_items.append(md(item, heading_style="ATX"))
+ parts.append(f"# {d}")
+ parts.append(c)
with open(file_path, "w", encoding="utf-8") as f:
- f.write("\n".join(md_items))
+ f.write("\n".join(parts))
def export_sql(self, file_path: str) -> None:
"""
diff --git a/bouquin/editor.py b/bouquin/editor.py
deleted file mode 100644
index ee45921..0000000
--- a/bouquin/editor.py
+++ /dev/null
@@ -1,1015 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-import base64, re
-
-from PySide6.QtGui import (
- QColor,
- QDesktopServices,
- QFont,
- QFontDatabase,
- QImage,
- QImageReader,
- QPalette,
- QPixmap,
- QTextCharFormat,
- QTextCursor,
- QTextFrameFormat,
- QTextListFormat,
- QTextBlockFormat,
- QTextImageFormat,
- QTextDocument,
-)
-from PySide6.QtCore import (
- Qt,
- QUrl,
- Signal,
- Slot,
- QRegularExpression,
- QBuffer,
- QByteArray,
- QIODevice,
- QTimer,
-)
-from PySide6.QtWidgets import QTextEdit, QApplication
-
-from .theme import Theme, ThemeManager
-
-
-class Editor(QTextEdit):
- linkActivated = Signal(str)
-
- _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)
- _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)
- tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
- self.setTabStopDistance(tab_w)
-
- self.setTextInteractionFlags(
- Qt.TextInteractionFlag.TextEditorInteraction
- | Qt.TextInteractionFlag.LinksAccessibleByMouse
- | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
- )
-
- self.setAcceptRichText(True)
-
- # If older docs have a baked-in color, normalize once:
- self._retint_anchors_to_palette()
-
- self._themes = theme_manager
- self._apply_code_theme() # set initial code colors
- # Refresh on theme change
- self._themes.themeChanged.connect(self._on_theme_changed)
- self._themes.themeChanged.connect(
- lambda _t: QTimer.singleShot(0, self._apply_code_theme)
- )
-
- self._linkifying = False
- self.textChanged.connect(self._linkify_document)
- self.viewport().setMouseTracking(True)
-
- # ---------------- Helpers ---------------- #
-
- def _iter_frames(self, root=None):
- """Depth-first traversal of all frames (including root if passed)."""
- doc = self.document()
- stack = [root or doc.rootFrame()]
- while stack:
- f = stack.pop()
- yield f
- it = f.begin()
- while not it.atEnd():
- cf = it.currentFrame()
- if cf is not None:
- stack.append(cf)
- it += 1
-
- def _is_code_frame(self, frame, tolerant: bool = False) -> bool:
- """
- True if 'frame' is a code frame.
- - tolerant=False: require our property marker
- - tolerant=True: also accept legacy background or non-wrapping heuristic
- """
- ff = frame.frameFormat()
- if ff.property(self._CODE_FRAME_PROP):
- return True
- if not tolerant:
- return False
-
- # Background colour check
- bg = ff.background()
- if bg.style() != Qt.NoBrush:
- c = bg.color()
- if c.isValid():
- if (
- abs(c.red() - 245) <= 2
- and abs(c.green() - 245) <= 2
- and abs(c.blue() - 245) <= 2
- ):
- return True
- if (
- abs(c.red() - 43) <= 2
- and abs(c.green() - 43) <= 2
- and abs(c.blue() - 43) <= 2
- ):
- return True
-
- # Heuristic: mostly non-wrapping blocks
- doc = self.document()
- bc = QTextCursor(doc)
- bc.setPosition(frame.firstPosition())
- blocks = codeish = 0
- while bc.position() < frame.lastPosition():
- b = bc.block()
- if not b.isValid():
- break
- blocks += 1
- if b.blockFormat().nonBreakableLines():
- codeish += 1
- bc.setPosition(b.position() + b.length())
- return blocks > 0 and (codeish / blocks) >= 0.6
-
- def _nearest_code_frame(self, cursor, tolerant: bool = False):
- """Walk up parents from the cursor and return the first code frame."""
- f = cursor.currentFrame()
- while f:
- if self._is_code_frame(f, tolerant=tolerant):
- return f
- f = f.parentFrame()
- return None
-
- def _code_block_formats(self, fg: QColor | None = None):
- """(QTextBlockFormat, QTextCharFormat) for code blocks."""
- mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
-
- bf = QTextBlockFormat()
- bf.setTopMargin(0)
- bf.setBottomMargin(0)
- bf.setLeftMargin(12)
- bf.setRightMargin(12)
- bf.setNonBreakableLines(True)
-
- cf = QTextCharFormat()
- cf.setFont(mono)
- cf.setFontFixedPitch(True)
- if fg is not None:
- cf.setForeground(fg)
- return bf, cf
-
- def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat:
- """Standard frame format for code blocks."""
- ff = QTextFrameFormat()
- ff.setBackground(bg)
- ff.setPadding(6)
- ff.setBorder(0)
- ff.setLeftMargin(0)
- ff.setRightMargin(0)
- ff.setTopMargin(0)
- ff.setBottomMargin(0)
- ff.setProperty(self._CODE_FRAME_PROP, True)
- return ff
-
- def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None):
- """Apply background to frame and standard code formats to all blocks inside."""
- ff = frame.frameFormat()
- ff.setBackground(bg)
- frame.setFrameFormat(ff)
-
- bf, cf = self._code_block_formats(fg)
- doc = self.document()
- bc = QTextCursor(doc)
- bc.setPosition(frame.firstPosition())
- while bc.position() < frame.lastPosition():
- bc.select(QTextCursor.BlockUnderCursor)
- bc.mergeBlockFormat(bf)
- bc.mergeBlockCharFormat(cf)
- if not bc.movePosition(QTextCursor.NextBlock):
- break
-
- def _safe_block_insertion_cursor(self):
- """
- Return a cursor positioned for inserting an inline object (like an image):
- - not inside a code frame (moves to after frame if necessary)
- - at a fresh paragraph (inserts a block if mid-line)
- Also updates the editor's current cursor to that position.
- """
- c = QTextCursor(self.textCursor())
- frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only
- if frame:
- out = QTextCursor(self.document())
- out.setPosition(frame.lastPosition())
- self.setTextCursor(out)
- c = self.textCursor()
- if c.positionInBlock() != 0:
- c.insertBlock()
- return c
-
- def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage:
- """If the image is wider than viewport*ratio, scale it down proportionally."""
- if self.viewport():
- max_w = int(self.viewport().width() * ratio)
- if img.width() > max_w:
- return img.scaledToWidth(max_w, Qt.SmoothTransformation)
- return img
-
- 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?"""
- bf = self.textCursor().blockFormat()
- if bf.headingLevel() > 0:
- return True
-
- 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 _code_theme_colors(self):
- """Return (bg, fg) for code blocks based on the effective palette."""
- pal = QApplication.instance().palette()
- # simple luminance check on the window color
- win = pal.color(QPalette.Window)
- is_dark = win.value() < 128
- if is_dark:
- bg = QColor(43, 43, 43) # dark code background
- fg = pal.windowText().color() # readable on dark
- else:
- bg = QColor(245, 245, 245) # light code background
- fg = pal.text().color() # readable on light
- return bg, fg
-
- def _apply_code_theme(self):
- """Retint all code frames (even those reloaded from HTML) to match the current theme."""
- bg, fg = self._code_theme_colors()
- self._CODE_BG = bg # used by future apply_code() calls
-
- doc = self.document()
- cur = QTextCursor(doc)
- cur.beginEditBlock()
- try:
- for f in self._iter_frames(doc.rootFrame()):
- if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True):
- self._retint_code_frame(f, bg, fg)
- finally:
- cur.endEditBlock()
- self.viewport().update()
-
- def _trim_url_end(self, url: str) -> str:
- # strip common trailing punctuation not part of the URL
- trimmed = url.rstrip(".,;:!?\"'")
- # drop an unmatched closing ) or ] at the very end
- if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
- trimmed = trimmed[:-1]
- if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
- trimmed = trimmed[:-1]
- return trimmed
-
- def _linkify_document(self):
- if self._linkifying:
- return
- self._linkifying = True
-
- try:
- block = self.textCursor().block()
- start_pos = block.position()
- text = block.text()
-
- cur = QTextCursor(self.document())
- cur.beginEditBlock()
-
- it = self._URL_RX.globalMatch(text)
- while it.hasNext():
- m = it.next()
- s = start_pos + m.capturedStart()
- raw = m.captured(0)
- url = self._trim_url_end(raw)
- if not url:
- continue
-
- e = s + len(url)
- cur.setPosition(s)
- cur.setPosition(e, QTextCursor.KeepAnchor)
-
- if url.startswith("www."):
- href = "https://" + url
- else:
- href = url
-
- fmt = QTextCharFormat()
- fmt.setAnchor(True)
- fmt.setAnchorHref(href) # always refresh to the latest full URL
- fmt.setFontUnderline(True)
- fmt.setForeground(self.palette().brush(QPalette.Link))
-
- cur.mergeCharFormat(fmt) # merge so we don't clobber other styling
-
- cur.endEditBlock()
- finally:
- self._linkifying = False
-
- def _to_qimage(self, obj) -> QImage | None:
- if isinstance(obj, QImage):
- return None if obj.isNull() else obj
- if isinstance(obj, QPixmap):
- qi = obj.toImage()
- return None if qi.isNull() else qi
- if isinstance(obj, (bytes, bytearray)):
- qi = QImage.fromData(obj)
- return None if qi.isNull() else qi
- return None
-
- def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
- ba = QByteArray()
- buf = QBuffer(ba)
- buf.open(QIODevice.WriteOnly)
- img.save(buf, fmt.upper())
- b64 = base64.b64encode(bytes(ba)).decode("ascii")
- mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
- return f"data:{mime};base64,{b64}"
-
- def _image_name_to_qimage(self, name: str) -> QImage | None:
- res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
- return res if isinstance(res, QImage) and not res.isNull() else None
-
- def to_html_with_embedded_images(self) -> str:
- """
- Return the document HTML with all image src's replaced by data: URLs,
- so it is self-contained for storage in the DB.
- """
- # 1) Walk the document collecting name -> data: URL
- name_to_data = {}
- cur = QTextCursor(self.document())
- cur.movePosition(QTextCursor.Start)
- while True:
- cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
- fmt = cur.charFormat()
- if fmt.isImageFormat():
- imgfmt = QTextImageFormat(fmt)
- name = imgfmt.name()
- if name and name not in name_to_data:
- img = self._image_name_to_qimage(name)
- if img:
- name_to_data[name] = self._qimage_to_data_url(img, "PNG")
- if cur.atEnd():
- break
- cur.clearSelection()
-
- # 2) Serialize and replace names with data URLs
- html = self.document().toHtml()
- for old, data_url in name_to_data.items():
- html = html.replace(f'src="{old}"', f'src="{data_url}"')
- html = html.replace(f"src='{old}'", f"src='{data_url}'")
- return html
-
- # ---------------- Image insertion & sizing (DRY’d) ---------------- #
-
- def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
- c = self._safe_block_insertion_cursor()
- if autoscale:
- img = self._scale_to_viewport(img)
- c.insertImage(img)
- c.insertBlock() # one blank line after the image
-
- def _image_info_at_cursor(self):
- """
- Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
- """
- # Try current position (select 1 char forward)
- tc = QTextCursor(self.textCursor())
- tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
- fmt = tc.charFormat()
- if fmt.isImageFormat():
- imgfmt = QTextImageFormat(fmt)
- img = self._resolve_image_resource(imgfmt)
- return tc, imgfmt, img
-
- # Try previous char (if caret is just after the image)
- tc = QTextCursor(self.textCursor())
- if tc.position() > 0:
- tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
- tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
- fmt = tc.charFormat()
- if fmt.isImageFormat():
- imgfmt = QTextImageFormat(fmt)
- img = self._resolve_image_resource(imgfmt)
- return tc, imgfmt, img
-
- return None, None, None
-
- def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
- """
- Fetch the original QImage backing the inline image, if available.
- """
- name = imgfmt.name()
- if name:
- try:
- img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
- if isinstance(img, QImage) and not img.isNull():
- return img
- except Exception:
- pass
- return None # fallback handled by callers
-
- def _apply_image_size(
- self,
- tc: QTextCursor,
- imgfmt: QTextImageFormat,
- new_w: float,
- orig_img: QImage | None,
- ):
- # compute height proportionally
- if orig_img and orig_img.width() > 0:
- ratio = new_w / orig_img.width()
- new_h = max(1.0, orig_img.height() * ratio)
- else:
- # fallback: keep current aspect ratio if we have it
- cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
- cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
- ratio = new_w / max(1.0, cur_w)
- new_h = max(1.0, cur_h * ratio)
-
- imgfmt.setWidth(max(1.0, new_w))
- imgfmt.setHeight(max(1.0, new_h))
- tc.mergeCharFormat(imgfmt)
-
- def _scale_image_at_cursor(self, factor: float):
- tc, imgfmt, orig = self._image_info_at_cursor()
- if not imgfmt:
- return
- base_w = imgfmt.width()
- if base_w <= 0 and orig:
- base_w = orig.width()
- if base_w <= 0:
- return
- self._apply_image_size(tc, imgfmt, base_w * factor, orig)
-
- def _fit_image_to_editor_width(self):
- tc, imgfmt, orig = self._image_info_at_cursor()
- if not imgfmt:
- return
- if not self.viewport():
- return
- target = int(self.viewport().width() * 0.92)
- self._apply_image_size(tc, imgfmt, target, orig)
-
- def _set_image_width_dialog(self):
- from PySide6.QtWidgets import QInputDialog
-
- tc, imgfmt, orig = self._image_info_at_cursor()
- if not imgfmt:
- return
- # propose current display width or original width
- cur_w = (
- int(imgfmt.width())
- if imgfmt.width() > 0
- else (orig.width() if orig else 400)
- )
- w, ok = QInputDialog.getInt(
- self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
- )
- if ok:
- self._apply_image_size(tc, imgfmt, float(w), orig)
-
- def _reset_image_size(self):
- tc, imgfmt, orig = self._image_info_at_cursor()
- if not imgfmt or not orig:
- return
- self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
-
- # ---------------- Context menu ---------------- #
-
- def contextMenuEvent(self, e):
- menu = self.createStandardContextMenu()
- tc, imgfmt, orig = self._image_info_at_cursor()
- if imgfmt:
- menu.addSeparator()
- sub = menu.addMenu("Image size")
- sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
- sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
- sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
- sub.addAction("Set width…", self._set_image_width_dialog)
- sub.addAction("Reset to original", self._reset_image_size)
- menu.exec(e.globalPos())
-
- # ---------------- Clipboard / DnD ---------------- #
-
- def insertFromMimeData(self, source):
- # 1) Direct image from clipboard
- if source.hasImage():
- img = self._to_qimage(source.imageData())
- if img is not None:
- self._insert_qimage_at_cursor(img, autoscale=True)
- return
-
- # 2) File URLs (drag/drop or paste)
- if source.hasUrls():
- paths = []
- non_local_urls = []
- for url in source.urls():
- if url.isLocalFile():
- path = url.toLocalFile()
- if path.lower().endswith(self._IMAGE_EXTS):
- paths.append(path)
- else:
- # Non-image file: insert as link
- self.textCursor().insertHtml(
- f'{Path(path).name}'
- )
- self.textCursor().insertBlock()
- else:
- non_local_urls.append(url)
-
- if paths:
- self.insert_images(paths)
-
- for url in non_local_urls:
- self.textCursor().insertHtml(
- f'{url.toString()}'
- )
- self.textCursor().insertBlock()
-
- if paths or non_local_urls:
- return
-
- # 3) HTML with data: image
- if source.hasHtml():
- html = source.html()
- m = self._DATA_IMG_RX.search(html or "")
- if m:
- try:
- data = base64.b64decode(m.group(1))
- img = QImage.fromData(data)
- if not img.isNull():
- self._insert_qimage_at_cursor(img, autoscale=True)
- return
- except Exception:
- pass # fall through
-
- # 4) Everything else → default behavior
- super().insertFromMimeData(source)
-
- @Slot(list)
- def insert_images(self, paths: list[str], autoscale=True):
- """
- Insert one or more images at the cursor. Large images can be auto-scaled
- to fit the viewport width while preserving aspect ratio.
- """
- c = self._safe_block_insertion_cursor()
-
- for path in paths:
- reader = QImageReader(path)
- img = reader.read()
- if img.isNull():
- continue
-
- if autoscale:
- img = self._scale_to_viewport(img)
-
- c.insertImage(img)
- c.insertBlock() # put each image on its own line
-
- # ---------------- Mouse & key handling ---------------- #
-
- def mouseReleaseEvent(self, e):
- if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
- href = self.anchorAt(e.pos())
- if href:
- QDesktopServices.openUrl(QUrl.fromUserInput(href))
- self.linkActivated.emit(href)
- return
- super().mouseReleaseEvent(e)
-
- def mouseMoveEvent(self, e):
- if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
- self.viewport().setCursor(Qt.PointingHandCursor)
- else:
- 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()
-
- 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)
-
- 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._nearest_code_frame(c, tolerant=False)
- if frame:
- out = QTextCursor(self.document())
- out.setPosition(frame.lastPosition()) # after the frame's contents
- self.setTextCursor(out)
- 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():
- super().keyPressEvent(e) # insert the new paragraph
- self._apply_normal_typing() # make the *new* paragraph Normal for typing
- return
-
- # If we were at end-of-line, make the *new* line plain (don’t keep URL styling)
- if not c.hasSelection() and c.atBlockEnd():
- super().keyPressEvent(e) # insert the new paragraph
- self._break_anchor_for_next_char() # clear anchor/underline/color for typing
- return
-
- # otherwise default handling
- return super().keyPressEvent(e)
-
- def _break_anchor_for_next_char(self):
- """
- 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()
- or ins_fmt.fontUnderline()
- or ins_fmt.foreground().style() != Qt.NoBrush
- ):
- return
-
- nf = QTextCharFormat(ins_fmt)
- # stop the link itself
- nf.setAnchor(False)
- nf.setAnchorHref("")
- # also stop the link *styling*
- nf.setFontUnderline(False)
- nf.clearForeground()
-
- self.setCurrentCharFormat(nf)
-
- def merge_on_sel(self, fmt):
- """
- Sets the styling on the selected characters or the insertion position.
- """
- cursor = self.textCursor()
- if cursor.hasSelection():
- 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()
-
- @Slot()
- def apply_weight(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- weight = (
- QFont.Weight.Normal
- if cur.fontWeight() == QFont.Weight.Bold
- else QFont.Weight.Bold
- )
- fmt.setFontWeight(weight)
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_italic(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontItalic(not cur.fontItalic())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_underline(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontUnderline(not cur.fontUnderline())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_strikethrough(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontStrikeOut(not cur.fontStrikeOut())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_code(self):
- c = self.textCursor()
- if not c.hasSelection():
- c.select(QTextCursor.BlockUnderCursor)
-
- ff = self._new_code_frame_format(self._CODE_BG)
-
- c.beginEditBlock()
- try:
- c.insertFrame(ff) # with a selection, this wraps the selection
-
- # Format all blocks inside the new frame (keep fg=None on creation)
- frame = self._nearest_code_frame(c, tolerant=False)
- if frame:
- self._retint_code_frame(frame, self._CODE_BG, fg=None)
- finally:
- c.endEditBlock()
-
- @Slot(int)
- 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.
- """
- # Map toolbar's sizes to heading levels
- level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0
-
- c = self.textCursor()
-
- # On-screen look
- ins = QTextCharFormat()
- if size:
- ins.setFontPointSize(float(size))
- ins.setFontWeight(QFont.Weight.Bold)
- else:
- ins.setFontPointSize(self.font().pointSizeF())
- ins.setFontWeight(QFont.Weight.Normal)
- self.mergeCurrentCharFormat(ins)
-
- # Apply heading level to affected block(s)
- def set_level_for_block(cur):
- bf = cur.blockFormat()
- if hasattr(bf, "setHeadingLevel"):
- bf.setHeadingLevel(level) # 0 clears heading
- cur.mergeBlockFormat(bf)
-
- if c.hasSelection():
- start, end = c.selectionStart(), c.selectionEnd()
- bc = QTextCursor(self.document())
- bc.setPosition(start)
- while True:
- set_level_for_block(bc)
- if bc.position() >= end:
- break
- bc.movePosition(QTextCursor.EndOfBlock)
- if bc.position() >= end:
- break
- bc.movePosition(QTextCursor.NextBlock)
- else:
- bc = QTextCursor(c)
- set_level_for_block(bc)
-
- def toggle_bullets(self):
- c = self.textCursor()
- lst = c.currentList()
- if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
- lst.remove(c.block())
- return
- fmt = QTextListFormat()
- fmt.setStyle(QTextListFormat.Style.ListDisc)
- c.createList(fmt)
-
- def toggle_numbers(self):
- c = self.textCursor()
- lst = c.currentList()
- if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
- lst.remove(c.block())
- return
- fmt = QTextListFormat()
- fmt.setStyle(QTextListFormat.Style.ListDecimal)
- c.createList(fmt)
-
- @Slot(Theme)
- def _on_theme_changed(self, _theme: Theme):
- # Defer one event-loop tick so widgets have the new palette
- QTimer.singleShot(0, self._retint_anchors_to_palette)
- QTimer.singleShot(0, self._apply_code_theme)
-
- @Slot()
- def _retint_anchors_to_palette(self, *_):
- # Always read from the *application* palette to avoid stale widget palette
- app = QApplication.instance()
- link_brush = app.palette().brush(QPalette.Link)
- doc = self.document()
- cur = QTextCursor(doc)
- cur.beginEditBlock()
- block = doc.firstBlock()
- while block.isValid():
- it = block.begin()
- while not it.atEnd():
- frag = it.fragment()
- if frag.isValid():
- fmt = frag.charFormat()
- if fmt.isAnchor():
- new_fmt = QTextCharFormat(fmt)
- new_fmt.setForeground(link_brush) # force palette link color
- start = frag.position()
- cur.setPosition(start)
- cur.movePosition(
- QTextCursor.NextCharacter,
- QTextCursor.KeepAnchor,
- frag.length(),
- ) # select exactly this fragment
- cur.setCharFormat(new_fmt)
- it += 1
- block = block.next()
- cur.endEditBlock()
- self.viewport().update()
-
- def setHtml(self, html: str) -> None:
- super().setHtml(html)
-
- doc = self.document()
- block = doc.firstBlock()
- while block.isValid():
- self._style_checkbox_glyph(block) # Apply checkbox styling to each block
- block = block.next()
-
- # Ensure anchors adopt the palette color on startup
- self._retint_anchors_to_palette()
- self._apply_code_theme()
diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py
index 0113ba1..1a4c029 100644
--- a/bouquin/history_dialog.py
+++ b/bouquin/history_dialog.py
@@ -16,31 +16,33 @@ from PySide6.QtWidgets import (
)
-def _html_to_text(s: str) -> str:
- """Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
- IMG_RE = re.compile(r"(?is)
]*>")
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?\1>")
- COMMENT_RE = re.compile(r"", re.S)
- BR_RE = re.compile(r"(?i)
")
- BLOCK_END_RE = re.compile(r"(?i)(p|div|section|article|li|h[1-6])\s*>")
- TAG_RE = re.compile(r"<[^>]+>")
- MULTINL_RE = re.compile(r"\n{3,}")
-
- s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
- s = STYLE_SCRIPT_RE.sub("", s)
- s = COMMENT_RE.sub("", s)
- s = BR_RE.sub("\n", s)
- s = BLOCK_END_RE.sub("\n", s)
- s = TAG_RE.sub("", s)
- s = _html.unescape(s)
- s = MULTINL_RE.sub("\n\n", s)
+def _markdown_to_text(s: str) -> str:
+ """Convert markdown to plain text for diff comparison."""
+ # Remove images
+ s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
+ # Remove inline code formatting
+ s = re.sub(r"`([^`]+)`", r"\1", s)
+ # Remove bold/italic markers
+ s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
+ s = re.sub(r"__([^_]+)__", r"\1", s)
+ s = re.sub(r"\*([^*]+)\*", r"\1", s)
+ s = re.sub(r"_([^_]+)_", r"\1", s)
+ # Remove strikethrough
+ s = re.sub(r"~~([^~]+)~~", r"\1", s)
+ # Remove heading markers
+ s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
+ # Remove list markers
+ s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
+ s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
+ # Remove checkbox markers
+ s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
return s.strip()
-def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
+def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
- a = _html_to_text(old_html).splitlines()
- b = _html_to_text(new_html).splitlines()
+ a = _markdown_to_text(old_md).splitlines()
+ b = _markdown_to_text(new_md).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
lines = []
for line in ud:
@@ -150,9 +152,13 @@ class HistoryDialog(QDialog):
self.btn_revert.setEnabled(False)
return
sel_id = item.data(Qt.UserRole)
- # Preview selected as HTML
+ # Preview selected as plain text (markdown)
sel = self._db.get_version(version_id=sel_id)
- self.preview.setHtml(sel["content"])
+ # Show markdown as plain text with monospace font for better readability
+ self.preview.setPlainText(sel["content"])
+ self.preview.setStyleSheet(
+ "font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
+ )
# Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 27934d4..234be2b 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -44,7 +44,7 @@ from PySide6.QtWidgets import (
)
from .db import DBManager
-from .editor import Editor
+from .markdown_editor import MarkdownEditor
from .find_bar import FindBar
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
@@ -99,7 +99,7 @@ class MainWindow(QMainWindow):
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor
- self.editor = Editor(self.themes)
+ self.editor = MarkdownEditor(self.themes)
# Toolbar for controlling styling
self.toolBar = ToolBar()
@@ -107,14 +107,14 @@ class MainWindow(QMainWindow):
# Wire toolbar intents to editor methods
self.toolBar.boldRequested.connect(self.editor.apply_weight)
self.toolBar.italicRequested.connect(self.editor.apply_italic)
- self.toolBar.underlineRequested.connect(self.editor.apply_underline)
+ # Note: Markdown doesn't support underline, so we skip underlineRequested
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
self.toolBar.codeRequested.connect(self.editor.apply_code)
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)
+ # Note: Markdown doesn't natively support alignment, removing alignRequested
self.toolBar.historyRequested.connect(self._open_history)
self.toolBar.insertImageRequested.connect(self._on_insert_image)
@@ -450,17 +450,14 @@ class MainWindow(QMainWindow):
def _sync_toolbar(self):
fmt = self.editor.currentCharFormat()
c = self.editor.textCursor()
- bf = c.blockFormat()
# Block signals so setChecked() doesn't re-trigger actions
QSignalBlocker(self.toolBar.actBold)
QSignalBlocker(self.toolBar.actItalic)
- QSignalBlocker(self.toolBar.actUnderline)
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
@@ -492,15 +489,6 @@ class MainWindow(QMainWindow):
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}"
@@ -511,14 +499,12 @@ class MainWindow(QMainWindow):
try:
text = self.db.get_entry(date_iso)
if extra_data:
- # Wrap extra_data in a tag for HTML rendering
- extra_data_html = f"
{extra_data}
"
-
- # Inject the extra_data before the closing