Compare commits

...

9 commits

14 changed files with 817 additions and 207 deletions

View file

@ -1,3 +1,15 @@
# 0.5.3
* Prevent triple-click select from selecting the list item (e.g checkbox, bullet)
* Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues.
* Change History icon (again)
* Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it)
* Prevent double-click of checkbox leading to selecting/highlighting it
* Slightly fade the text of a checkbox line if the checkbox is checked.
* Fix weekend date colours being incorrect on theme change while app is running
* Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
* Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
# 0.5.2
* Update icon again to remove background

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QPlainTextEdit,
QDialogButtonBox,
QComboBox,
QLabel,
)
from . import strings
class CodeBlockEditorDialog(QDialog):
def __init__(self, code: str, language: str | None, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("edit_code_block"))
self.setMinimumSize(650, 650)
self._code_edit = QPlainTextEdit(self)
self._code_edit.setPlainText(code)
# Language selector (optional)
self._lang_combo = QComboBox(self)
languages = [
"",
"bash",
"css",
"html",
"javascript",
"php",
"python",
]
self._lang_combo.addItems(languages)
if language and language in languages:
self._lang_combo.setCurrentText(language)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(strings._("locale") + ":", self))
layout.addWidget(self._lang_combo)
layout.addWidget(self._code_edit)
layout.addWidget(buttons)
def code(self) -> str:
return self._code_edit.toPlainText()
def language(self) -> str | None:
text = self._lang_combo.currentText().strip()
return text or None

View file

@ -348,7 +348,7 @@ class CodeBlockMetadata:
return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->"
return "<!-- code-langs: " + ",".join(items) + " -->\n"
def deserialize(self, text: str):
"""Deserialize metadata from text."""

View file

@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

Binary file not shown.

Binary file not shown.

View file

@ -289,5 +289,6 @@
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"day": "Day"
"day": "Day",
"edit_code_block": "Edit code block"
}

View file

@ -1026,10 +1026,12 @@ class MainWindow(QMainWindow):
self.editor.viewport().update()
def _apply_calendar_text_colors(self):
pal = self.palette()
pal = QApplication.instance().palette()
txt = pal.windowText().color()
fmt = QTextCharFormat()
fmt.setForeground(txt)
# Use normal text color for weekends
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
@ -1204,7 +1206,8 @@ class MainWindow(QMainWindow):
return
# Get the current line text
line_text = editor.get_current_line_text().strip()
line_text = editor.get_current_line_task_text()
if not line_text:
line_text = strings._("pomodoro_time_log_default_text")

View file

@ -3,12 +3,15 @@ from __future__ import annotations
import base64
import re
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtGui import (
QFont,
QFontDatabase,
QFontMetrics,
QImage,
QMouseEvent,
QTextBlock,
QTextCharFormat,
QTextCursor,
QTextDocument,
@ -18,10 +21,11 @@ from PySide6.QtGui import (
QDesktopServices,
)
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QTextEdit
from PySide6.QtWidgets import QDialog, QTextEdit
from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter
from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings
@ -46,18 +50,14 @@ class MarkdownEditor(QTextEdit):
base_dir = Path(__file__).resolve().parent
# Load regular text font (primary)
regular_font_path = base_dir / "fonts" / "NotoSans-Regular.ttf"
regular_font_path = base_dir / "fonts" / "DejaVuSans.ttf"
regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path))
if regular_font_id == -1:
print("Failed to load NotoSans-Regular.ttf")
# Load Symbols font (fallback)
symbols_font_path = base_dir / "fonts" / "NotoSansSymbols2-Regular.ttf"
symbols_font_id = QFontDatabase.addApplicationFont(str(symbols_font_path))
symbols_families = QFontDatabase.applicationFontFamilies(symbols_font_id)
self.symbols_font_family = symbols_families[0]
if symbols_font_id == -1:
print("Failed to load NotoSansSymbols2-Regular.ttf")
# Use the regular Noto Sans family as the editor font
regular_families = QFontDatabase.applicationFontFamilies(regular_font_id)
@ -92,6 +92,16 @@ class MarkdownEditor(QTextEdit):
# Track if we're currently updating text programmatically
self._updating = False
# Help avoid double-click selecting of checkbox
self._suppress_next_checkbox_double_click = False
# Guard to avoid recursive selection tweaks
self._adjusting_selection = False
# After selections change, trim list prefixes from full-line selections
# (e.g. after triple-clicking a list item to select the line).
self.selectionChanged.connect(self._maybe_trim_list_prefix_from_line_selection)
# Connect to text changes for smart formatting
self.textChanged.connect(self._on_text_changed)
self.textChanged.connect(self._update_code_block_row_backgrounds)
@ -239,6 +249,153 @@ class MarkdownEditor(QTextEdit):
]
self.setExtraSelections(others + sels)
def _find_code_block_bounds(
self, block: QTextBlock
) -> Optional[Tuple[QTextBlock, QTextBlock]]:
"""
Given a block that is either inside a fenced code block or on a fence,
return (opening_fence_block, closing_fence_block).
Returns None if we can't find a proper pair.
"""
if not block.isValid():
return None
def is_fence(b: QTextBlock) -> bool:
return b.isValid() and b.text().strip().startswith("```")
# If we're on a fence line, decide if it's opening or closing
if is_fence(block):
# If we're "inside" just before this fence, this one closes.
if self._is_inside_code_block(block.previous()):
close_block = block
open_block = block.previous()
while open_block.isValid() and not is_fence(open_block):
open_block = open_block.previous()
if not is_fence(open_block):
return None
return open_block, close_block
else:
# Treat as opening fence; search downward for the closing one.
open_block = block
close_block = open_block.next()
while close_block.isValid() and not is_fence(close_block):
close_block = close_block.next()
if not is_fence(close_block):
return None
return open_block, close_block
# Normal interior line: search up for opening fence, down for closing.
open_block = block.previous()
while open_block.isValid() and not is_fence(open_block):
open_block = open_block.previous()
if not is_fence(open_block):
return None
close_block = open_block.next()
while close_block.isValid() and not is_fence(close_block):
close_block = close_block.next()
if not is_fence(close_block):
return None
return open_block, close_block
def _get_code_block_text(
self, open_block: QTextBlock, close_block: QTextBlock
) -> str:
"""Return the inner text (between fences) as a normal '\\n'-joined string."""
lines = []
b = open_block.next()
while b.isValid() and b != close_block:
lines.append(b.text())
b = b.next()
return "\n".join(lines)
def _replace_code_block_text(
self, open_block: QTextBlock, close_block: QTextBlock, new_text: str
) -> None:
"""
Replace everything between the two fences with `new_text`.
Fences themselves are left untouched.
"""
doc = self.document()
if doc is None:
return
cursor = QTextCursor(doc)
# Start just after the opening fence's newline
start_pos = open_block.position() + len(open_block.text())
# End at the start of the closing fence
end_pos = close_block.position()
cursor.setPosition(start_pos)
cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
cursor.beginEditBlock()
# Normalise trailing newline(s)
new_text = new_text.rstrip("\n")
if new_text:
cursor.removeSelectedText()
cursor.insertText("\n" + new_text + "\n")
else:
# Empty block keep one blank line inside the fences
cursor.removeSelectedText()
cursor.insertText("\n\n")
cursor.endEditBlock()
# Re-apply spacing and backgrounds
if hasattr(self, "_apply_code_block_spacing"):
self._apply_code_block_spacing()
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# Trigger rehighlight
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
def _edit_code_block(self, block: QTextBlock) -> bool:
"""Open a popup editor for the code block containing `block`.
Returns True if a dialog was shown (regardless of OK/Cancel),
False if no well-formed fenced block was found.
"""
bounds = self._find_code_block_bounds(block)
if not bounds:
return False
open_block, close_block = bounds
# Current language from metadata (if any)
lang = None
if hasattr(self, "_code_metadata"):
lang = self._code_metadata.get_language(open_block.blockNumber())
code_text = self._get_code_block_text(open_block, close_block)
dlg = CodeBlockEditorDialog(code_text, lang, parent=self)
result = dlg.exec()
if result != QDialog.DialogCode.Accepted:
# Dialog was shown but user cancelled; event is "handled".
return True
new_code = dlg.code()
new_lang = dlg.language()
# Update document text but keep fences
self._replace_code_block_text(open_block, close_block, new_code)
# Update metadata language if changed
if new_lang is not None:
if not hasattr(self, "_code_metadata"):
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
self._code_metadata.set_language(open_block.blockNumber(), new_lang)
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
return True
def _apply_line_spacing(self, height: float = 125.0):
"""Apply proportional line spacing to the whole document."""
doc = self.document()
@ -293,6 +450,30 @@ class MarkdownEditor(QTextEdit):
cursor.endEditBlock()
def _ensure_escape_line_after_closing_fence(self, fence_block: QTextBlock) -> None:
"""
Ensure there is at least one block *after* the given closing fence line.
If the fence is the last block in the document, we append a newline,
so the caret can always move outside the code block.
"""
doc = self.document()
if doc is None or not fence_block.isValid():
return
after = fence_block.next()
if after.isValid():
# There's already a block after the fence; nothing to do.
return
# No block after fence → create a blank line
cursor = QTextCursor(doc)
cursor.beginEditBlock()
endpos = fence_block.position() + len(fence_block.text())
cursor.setPosition(endpos)
cursor.insertText("\n")
cursor.endEditBlock()
def to_markdown(self) -> str:
"""Export current content as markdown."""
# First, extract any embedded images and convert to markdown
@ -486,6 +667,69 @@ class MarkdownEditor(QTextEdit):
return 0
def _maybe_trim_list_prefix_from_line_selection(self) -> None:
"""
If the current selection looks like a full-line selection on a list item
(for example, from a triple-click), trim the selection so that it starts
just *after* the visual list prefix (checkbox / bullet / number), and
ends at the end of the text on that line (not on the next line's newline).
"""
# Avoid re-entry when we move the cursor ourselves.
if getattr(self, "_adjusting_selection", False):
return
cursor = self.textCursor()
if not cursor.hasSelection():
return
start = cursor.selectionStart()
end = cursor.selectionEnd()
if start == end:
return
doc = self.document()
# 'end' is exclusive; use end - 1 so we land in the last selected block.
start_block = doc.findBlock(start)
end_block = doc.findBlock(end - 1)
if not start_block.isValid() or start_block != end_block:
# Only adjust single-line selections.
return
# How much list prefix (indent + checkbox/bullet/number) this block has
prefix_len = self._list_prefix_length_for_block(start_block)
if prefix_len <= 0:
return
block_start = start_block.position()
prefix_end = block_start + prefix_len
# If the selection already starts after the prefix, nothing to do.
if start >= prefix_end:
return
line_text = start_block.text()
line_end = block_start + len(line_text) # end of visible text on this line
# Only treat it as a "full line" selection if it reaches the end of the
# visible text. Triple-click usually selects to at least here (often +1 for
# the newline).
if end < line_end:
return
# Clamp the selection so that it ends at the end of this line's text,
# *not* at the newline / start of the next block. This keeps the caret
# blinking on the selected line instead of the next line.
visual_end = line_end
self._adjusting_selection = True
try:
new_cursor = self.textCursor()
new_cursor.setPosition(prefix_end)
new_cursor.setPosition(visual_end, QTextCursor.KeepAnchor)
self.setTextCursor(new_cursor)
finally:
self._adjusting_selection = False
def _detect_list_type(self, line: str) -> tuple[str | None, str]:
"""
Detect if line is a list item. Returns (list_type, prefix).
@ -542,38 +786,77 @@ class MarkdownEditor(QTextEdit):
def keyPressEvent(self, event):
"""Handle special key events for markdown editing."""
c = self.textCursor()
block = c.block()
# --- Auto-close code fences when typing the 3rd backtick at line start ---
if event.text() == "`":
c = self.textCursor()
block = c.block()
in_code = self._is_inside_code_block(block)
is_fence_line = block.text().strip().startswith("```")
# --- NEW: 3rd backtick shortcut → open code block dialog ---
# Only when we're *not* already in a code block or on a fence line.
if event.text() == "`" and not (in_code or is_fence_line):
line = block.text()
pos_in_block = c.position() - block.position()
# text before caret on this line
before = line[:pos_in_block]
# If we've typed exactly two backticks at line start (or after whitespace),
# treat this backtick as the "third" and expand to a full fenced block.
# "before" currently contains whatever's before the *third* backtick.
# We trigger only when the line is (whitespace + "``") before the caret.
if before.endswith("``") and before.strip() == "``":
start = (
block.position() + pos_in_block - 2
) # start of the two backticks
doc = self.document()
if doc is not None:
# Remove the two backticks that were already typed
start = block.position() + pos_in_block - 2
edit = QTextCursor(doc)
edit.beginEditBlock()
edit.setPosition(start)
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
edit.removeSelectedText()
edit.endEditBlock()
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(start)
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
edit.insertText("```\n\n```\n")
edit.endEditBlock()
# Move caret to where the code block should start
c.setPosition(start)
self.setTextCursor(c)
# place caret on the blank line between the fences
new_pos = start + 4 # after "```\n"
c.setPosition(new_pos)
self.setTextCursor(c)
# Now behave exactly like the </> toolbar button
self.apply_code()
return
# ------------------------------------------------------------
# If we're anywhere in a fenced code block (including the fences),
# treat the text as read-only and route edits through the dialog.
if in_code or is_fence_line:
key = event.key()
# Navigation keys that are safe to pass through.
nav_keys_no_down = (
Qt.Key.Key_Left,
Qt.Key.Key_Right,
Qt.Key.Key_Up,
Qt.Key.Key_Home,
Qt.Key.Key_End,
Qt.Key.Key_PageUp,
Qt.Key.Key_PageDown,
)
# Let these through:
# - pure navigation (except Down, which we handle specially later)
# - Enter/Return and Down, which are handled by dedicated logic below
if key in nav_keys_no_down:
super().keyPressEvent(event)
return
# Step out of a code block with Down at EOF
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Down):
# Let the existing Enter/Down code see these.
pass
else:
# Any other key (Backspace, Delete, characters, Tab, etc.)
# opens the code-block editor instead of editing inline.
if not self._edit_code_block(block):
# Fallback if bounds couldn't be found for some reason.
super().keyPressEvent(event)
return
# --- Step out of a code block with Down at EOF ---
if event.key() == Qt.Key.Key_Down:
c = self.textCursor()
b = c.block()
@ -584,7 +867,8 @@ class MarkdownEditor(QTextEdit):
nb = bb.next()
return nb.isValid() and nb.text().strip().startswith("```")
# Case A: caret is on the line BEFORE the closing fence, at EOL → jump after the fence
# Case A: caret is on the line BEFORE the closing fence, at EOL
# → jump after the fence
if (
self._is_inside_code_block(b)
and pos_in_block >= len(line)
@ -605,7 +889,8 @@ class MarkdownEditor(QTextEdit):
self._update_code_block_row_backgrounds()
return
# Case B: caret is ON the closing fence, and it's EOF → create a line and move to it
# Case B: caret is ON the closing fence, and it's EOF
# → create a line and move to it
if (
b.text().strip().startswith("```")
and self._is_inside_code_block(b)
@ -775,9 +1060,11 @@ class MarkdownEditor(QTextEdit):
return
# Inside a code block (but not on a fence): newline stays code-style
# Inside a code block (but not on a fence): open the popup editor
if block_state == 1:
super().keyPressEvent(event)
if not self._edit_code_block(current_block):
# Fallback if something is malformed
super().keyPressEvent(event)
return
# Check for list continuation
@ -853,6 +1140,9 @@ class MarkdownEditor(QTextEdit):
def mousePressEvent(self, event):
"""Toggle a checkbox only when the click lands on its icon."""
# default: don't suppress any upcoming double-click
self._suppress_next_checkbox_double_click = False
if event.button() == Qt.LeftButton:
pt = event.pos()
@ -894,7 +1184,15 @@ class MarkdownEditor(QTextEdit):
doc_pos = block.position() + i
r = char_rect_at(doc_pos, icon)
if r.contains(pt):
# ---------- Relax the hit area here ----------
# Expand the clickable area horizontally so you don't have to
# land exactly on the glyph. This makes the "checkbox zone"
# roughly 3× the glyph width, centered on it.
pad = r.width() # one glyph width on each side
hit_rect = r.adjusted(-pad, 0, pad, 0)
# ---------------------------------------------
if hit_rect.contains(pt):
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
new_icon = (
self._CHECK_CHECKED_DISPLAY
@ -910,6 +1208,9 @@ class MarkdownEditor(QTextEdit):
)
edit.insertText(f"{new_icon} ")
edit.endEditBlock()
# if a double-click comes next, ignore it
self._suppress_next_checkbox_double_click = True
return # handled
# advance past this token
@ -920,6 +1221,27 @@ class MarkdownEditor(QTextEdit):
# Default handling for anything else
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
# If the previous press toggled a checkbox, swallow this double-click
# so the base class does NOT turn it into a selection.
if getattr(self, "_suppress_next_checkbox_double_click", False):
self._suppress_next_checkbox_double_click = False
event.accept()
return
cursor = self.cursorForPosition(event.pos())
block = cursor.block()
# If were on or inside a code block, open the editor instead
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
# Only swallow the double-click if we actually opened a dialog.
if not self._edit_code_block(block):
super().mouseDoubleClickEvent(event)
return
# Otherwise, let normal double-click behaviour happen
super().mouseDoubleClickEvent(event)
# ------------------------ Toolbar action handlers ------------------------
def apply_weight(self):
@ -991,86 +1313,106 @@ class MarkdownEditor(QTextEdit):
self.setFocus()
def apply_code(self):
"""Insert a fenced code block, or navigate fences without creating inline backticks."""
c = self.textCursor()
"""
Toolbar handler for the </> button.
- If the caret is on / inside an existing fenced block, open the editor for it.
- Otherwise open the editor prefilled with any selected text, then insert a new
fenced block containing whatever the user typed.
"""
cursor = self.textCursor()
doc = self.document()
if c.hasSelection():
# Wrap selection and ensure exactly one newline after the closing fence
selected = c.selectedText().replace("\u2029", "\n")
c.insertText(f"```\n{selected.rstrip()}\n```\n")
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# tighten spacing for the new code block
self._apply_code_block_spacing()
self.setFocus()
if doc is None:
return
block = c.block()
line = block.text()
pos_in_block = c.position() - block.position()
stripped = line.strip()
block = cursor.block()
# If we're on a fence line, be helpful but never insert inline fences
if stripped.startswith("```"):
# Is this fence opening or closing? (look at blocks above)
inside_before = self._is_inside_code_block(block.previous())
if inside_before:
# This fence closes the block → ensure a line after, then move there
endpos = block.position() + len(line)
edit = QTextCursor(doc)
edit.setPosition(endpos)
if not block.next().isValid():
edit.insertText("\n")
c.setPosition(endpos + 1)
self.setTextCursor(c)
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
self.setFocus()
return
else:
# Opening fence → move caret to the next line (inside the block)
nb = block.next()
if not nb.isValid():
e = QTextCursor(doc)
e.setPosition(block.position() + len(line))
e.insertText("\n")
nb = block.next()
c.setPosition(nb.position())
self.setTextCursor(c)
self.setFocus()
return
# If we're inside a block (but not on a fence), don't mutate text
if self._is_inside_code_block(block):
self.setFocus()
# --- Case 1: already in a code block -> just edit that block ---
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
self._edit_code_block(block)
return
# Outside any block → create a clean template on its own lines (never inline)
start_pos = c.position()
before = line[:pos_in_block]
# --- Case 2: creating a new block (optional selection) ---
if cursor.hasSelection():
start_pos = cursor.selectionStart()
end_pos = cursor.selectionEnd()
# QTextEdit joins lines with U+2029 in selectedText()
initial_code = cursor.selectedText().replace("\u2029", "\n")
else:
start_pos = cursor.position()
end_pos = start_pos
initial_code = ""
# Let the user type/edit the code in the popup first
dlg = CodeBlockEditorDialog(initial_code, language=None, parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
code_text = dlg.code()
language = dlg.language()
# Don't insert an entirely empty block
if not code_text.strip():
return
code_text = code_text.rstrip("\n")
edit = QTextCursor(doc)
edit.beginEditBlock()
# If there is text before the caret on the line, start the block on a new line
lead_break = "\n" if before else ""
# Insert the block; trailing newline guarantees you can Down-arrow out later
insert = f"{lead_break}```\n\n```\n"
# Remove selection (if any) so we can insert the new fenced block
edit.setPosition(start_pos)
edit.insertText(insert)
edit.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
edit.removeSelectedText()
# Work out whether we're mid-line and need to break before the fence
block = doc.findBlock(start_pos)
line = block.text()
pos_in_block = start_pos - block.position()
before = line[:pos_in_block]
# If there's text before the caret on this line, put the fence on a new line
lead_break = "\n" if before else ""
insert_str = f"{lead_break}```\n{code_text}\n```\n"
edit.setPosition(start_pos)
edit.insertText(insert_str)
edit.endEditBlock()
# Put caret on the blank line inside the block
c.setPosition(start_pos + len(lead_break) + 4) # after "```\n"
self.setTextCursor(c)
# Find the opening fence block we just inserted
open_block = doc.findBlock(start_pos + len(lead_break))
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# Find the closing fence block
close_block = open_block.next()
while close_block.isValid() and not close_block.text().strip().startswith(
"```"
):
close_block = close_block.next()
# tighten spacing for the new code block
if close_block.isValid():
# Make sure there's always at least one line *after* the block
self._ensure_escape_line_after_closing_fence(close_block)
# Store language metadata if the user chose one
if language is not None:
if not hasattr(self, "_code_metadata"):
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
self._code_metadata.set_language(open_block.blockNumber(), language)
# Refresh visuals
self._apply_code_block_spacing()
self._update_code_block_row_backgrounds()
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
# Put caret just after the code block so the user can keep writing normal text
after_block = close_block.next() if close_block.isValid() else None
if after_block and after_block.isValid():
cursor = self.textCursor()
cursor.setPosition(after_block.position())
self.setTextCursor(cursor)
self.setFocus()
@ -1257,15 +1599,12 @@ class MarkdownEditor(QTextEdit):
lang_menu = menu.addMenu(strings._("set_code_language"))
languages = [
"python",
"bash",
"php",
"javascript",
"html",
"css",
"sql",
"java",
"go",
"html",
"javascript",
"php",
"python",
]
for lang in languages:
action = QAction(lang.capitalize(), self)
@ -1276,6 +1615,12 @@ class MarkdownEditor(QTextEdit):
menu.addSeparator()
edit_action = QAction(strings._("edit_code_block"), self)
edit_action.triggered.connect(lambda: self._edit_code_block(block))
menu.addAction(edit_action)
menu.addSeparator()
# Add standard context menu actions
if self.textCursor().hasSelection():
menu.addAction(strings._("cut"), self.cut)
@ -1309,3 +1654,23 @@ class MarkdownEditor(QTextEdit):
cursor = self.textCursor()
block = cursor.block()
return block.text()
def get_current_line_task_text(self) -> str:
"""
Like get_current_line_text(), but with list / checkbox / number
prefixes stripped off for use in Pomodoro notes, etc.
"""
line = self.get_current_line_text()
text = re.sub(
r"^\s*(?:"
r"-\s\[(?: |x|X)\]\s+" # markdown checkbox
r"|[☐☑]\s+" # Unicode checkbox
r"|•\s+" # Unicode bullet
r"|[-*+]\s+" # markdown bullets
r"|\d+\.\s+" # numbered 1. 2. etc
r")",
"",
line,
)
return text.strip()

View file

@ -78,17 +78,18 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.theme_manager.current() == Theme.DARK
or self.theme_manager._is_system_dark
):
# In dark mode, use a darker panel-like background
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
# In dark mode, use a darker panel-like background for codeblocks
code_bg = pal.color(QPalette.AlternateBase)
code_fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = QColor( # pragma: no cover
# Light mode: keep the existing light gray for code blocks
code_bg = QColor(245, 245, 245)
code_fg = QColor( # pragma: no cover
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)
self.code_block_format.setBackground(code_bg)
self.code_block_format.setForeground(code_fg)
# Headings
self.h1_format = QTextCharFormat()
@ -110,6 +111,23 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.link_format.setFontUnderline(True)
self.link_format.setAnchor(True)
# ---- Completed-task text (for checked checkboxes) ----
# Use the app palette so this works in both light and dark themes.
text_fg = pal.color(QPalette.Text)
text_bg = pal.color(QPalette.Base)
# Blend the text colour towards the background to "fade" it.
# t closer to 1.0 = closer to background / more faded.
t = 0.55
faded = QColor(
int(text_fg.red() * (1.0 - t) + text_bg.red() * t),
int(text_fg.green() * (1.0 - t) + text_bg.green() * t),
int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t),
)
self.completed_task_format = QTextCharFormat()
self.completed_task_format.setForeground(faded)
# Checkboxes
self.checkbox_format = QTextCharFormat()
self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle)
@ -140,8 +158,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
# Use the editor background color so they blend in
bg = pal.color(QPalette.Base)
hidden = QColor(bg)
hidden = QColor(text_bg)
hidden.setAlpha(0)
self.syntax_format.setForeground(hidden)
# Make the markers invisible by setting font size to 0.1 points
@ -342,3 +359,13 @@ class MarkdownHighlighter(QSyntaxHighlighter):
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format)
# Completed checkbox lines: fade the text after the checkbox.
m = re.match(r"^(\s*☑\s+)(.+)$", text)
if m and hasattr(self, "completed_task_format"):
prefix = m.group(1)
content = m.group(2)
start = len(prefix)
length = len(content)
if length > 0:
self._overlay_range(start, length, self.completed_task_format)

View file

@ -106,7 +106,7 @@ class ToolBar(QToolBar):
self.actInsertImg.triggered.connect(self.insertImageRequested)
# History button
self.actHistory = QAction("🔁", self)
self.actHistory = QAction("", self)
self.actHistory.setToolTip(strings._("history"))
self.actHistory.triggered.connect(self.historyRequested)
@ -187,7 +187,7 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actTimer, "")
# History
self._style_letter_button(self.actHistory, "🔁")
self._style_letter_button(self.actHistory, "")
def _style_letter_button(
self,

View file

@ -58,3 +58,38 @@ def fresh_db(tmp_db_cfg):
assert ok, "DB connect() should succeed"
yield db
db.close()
@pytest.fixture(autouse=True)
def _stub_code_block_editor_dialog(monkeypatch):
"""
In tests, replace the interactive CodeBlockEditorDialog with a tiny stub
that never shows a real QDialog and never blocks on exec().
"""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class _TestCodeBlockEditorDialog:
def __init__(self, code: str, language: str | None, parent=None):
# Simulate what the real dialog would “start with”
self._code = code
self._language = language
def exec(self) -> int:
# Pretend the user clicked OK immediately.
# (If you prefer “Cancel by default”, return Rejected instead.)
return QDialog.DialogCode.Accepted
def code(self) -> str:
# In tests we just return the initial code unchanged.
return self._code
def language(self) -> str | None:
# Ditto for language.
return self._language
# MarkdownEditor imported CodeBlockEditorDialog into its own module,
# so patch that name everything in MarkdownEditor will use this stub.
monkeypatch.setattr(
markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog
)

View file

@ -164,81 +164,22 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
assert editor.toPlainText().startswith("\u2022 \n")
def test_triple_backtick_autoexpands(editor, qtbot):
def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot):
# Start empty
editor.from_markdown("")
press_backtick(qtbot, editor, 2)
press_backtick(qtbot, editor, 1) # triggers expansion
press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut
qtbot.wait(0)
t = text(editor)
assert t.count("```") == 2
assert t.startswith("```\n\n```")
assert t.endswith("\n")
# caret is on the blank line inside the block
assert editor.textCursor().blockNumber() == 1
assert lines_keep(editor)[1] == ""
# The two typed backticks should have been removed
assert "`" not in t
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
editor.from_markdown("hello")
editor.moveCursor(QTextCursor.End)
editor.apply_code() # </> action inserts fenced code block
qtbot.wait(0)
t = text(editor)
assert "hello```" not in t # never inline
assert t.startswith("hello\n```")
assert t.endswith("```\n")
# caret inside block (blank line)
assert editor.textCursor().blockNumber() == 2
assert lines_keep(editor)[2] == ""
def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot):
editor.from_markdown("")
editor.apply_code() # create a block (caret now on blank line inside)
qtbot.wait(0)
pos_before = editor.textCursor().position()
t_before = text(editor)
editor.apply_code() # pressing </> inside should be a no-op
qtbot.wait(0)
assert text(editor) == t_before
assert editor.textCursor().position() == pos_before
def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot):
editor.from_markdown("")
editor.apply_code()
qtbot.wait(0)
# Go to opening fence (line 0)
editor.moveCursor(QTextCursor.Start)
editor.apply_code() # should jump inside the block
qtbot.wait(0)
assert editor.textCursor().blockNumber() == 1
assert lines_keep(editor)[1] == ""
def test_toolbar_on_closing_fence_jumps_out(editor, qtbot):
editor.from_markdown("")
editor.apply_code()
qtbot.wait(0)
# Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after)
editor.moveCursor(QTextCursor.End) # blank-after
editor.moveCursor(QTextCursor.Up) # closing fence
editor.moveCursor(QTextCursor.StartOfLine)
editor.apply_code() # jump to the line after the fence
qtbot.wait(0)
# Now on the blank line after the block
assert editor.textCursor().block().text() == ""
assert editor.textCursor().block().previous().text().strip() == "```"
# With the new dialog-based implementation, and our test stub that accepts
# the dialog with empty code, no fenced code block is inserted.
assert "```" not in t
assert t == ""
def test_down_escapes_from_last_code_line(editor, qtbot):
@ -522,25 +463,6 @@ def test_apply_italic_and_strike(editor):
assert editor.textCursor().position() == len(editor.toPlainText()) - 2
def test_apply_code_inline_block_navigation(editor):
# Selection case -> fenced block around selection
editor.setPlainText("code")
c = editor.textCursor()
c.select(QTextCursor.SelectionType.Document)
editor.setTextCursor(c)
editor.apply_code()
assert "```\ncode\n```\n" in editor.toPlainText()
# No selection, at EOF with no following block -> creates block and extra newline path
editor.setPlainText("before")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_code()
t = editor.toPlainText()
assert t.endswith("before\n```\n\n```\n")
# Caret should be inside the code block blank line
assert editor.textCursor().position() == len("before\n") + 4
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
# Non-existent path should just return (early exit)
bad = tmp_path / "missing.png"