Compare commits
9 commits
1d94c04551
...
57f11abb99
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f11abb99 | |||
| 7a207df0f3 | |||
| f6fa0aa997 | |||
| 1a56fa80ca | |||
| e160827708 | |||
| 4029d7604e | |||
| a56d6512d3 | |||
| f3ddd2a83c | |||
| a3c74a218f |
14 changed files with 817 additions and 207 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
58
bouquin/code_block_editor_dialog.py
Normal file
58
bouquin/code_block_editor_dialog.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
187
bouquin/fonts/DejaVu.license
Normal file
187
bouquin/fonts/DejaVu.license
Normal 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$
|
||||
BIN
bouquin/fonts/DejaVuSans.ttf
Normal file
BIN
bouquin/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -289,5 +289,6 @@
|
|||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"day": "Day"
|
||||
"day": "Day",
|
||||
"edit_code_block": "Edit code block"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 we’re 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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue