├── .clang-format ├── .git-blame-ignore-revs ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── .travis.yml ├── CMakeLists.txt ├── LICENSE ├── README.md ├── appveyor.yml ├── examples └── qml │ ├── example.cpp │ ├── example.qml │ └── ressources.qrc ├── justfile ├── linenumberarea.h ├── main.cpp ├── mainwindow.cpp ├── mainwindow.h ├── mainwindow.ui ├── markdownhighlighter.cpp ├── markdownhighlighter.h ├── media.qrc ├── media ├── edit-find-replace.svg ├── format-text-superscript.svg ├── go-bottom.svg ├── go-top.svg └── window-close.svg ├── old_screenshot.png ├── qmarkdowntextedit-app.pro ├── qmarkdowntextedit-headers.pri ├── qmarkdowntextedit-lib.pro ├── qmarkdowntextedit-sources.pri ├── qmarkdowntextedit.cpp ├── qmarkdowntextedit.h ├── qmarkdowntextedit.pc.in ├── qmarkdowntextedit.pri ├── qmarkdowntextedit.pro ├── qownlanguagedata.cpp ├── qownlanguagedata.h ├── qplaintexteditsearchwidget.cpp ├── qplaintexteditsearchwidget.h ├── qplaintexteditsearchwidget.ui ├── screenshot.png ├── scripts └── clang-format-project.sh ├── trans ├── qmarkdowntextedit_de.qm ├── qmarkdowntextedit_de.ts ├── qmarkdowntextedit_es.qm ├── qmarkdowntextedit_es.ts ├── qmarkdowntextedit_ur.qm ├── qmarkdowntextedit_ur.ts ├── qmarkdowntextedit_zh_CN.qm └── qmarkdowntextedit_zh_CN.ts └── treefmt.toml /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | # BasedOnStyle: Google 4 | AccessModifierOffset: -1 5 | AlignAfterOpenBracket: Align 6 | AlignConsecutiveMacros: false 7 | AlignConsecutiveAssignments: false 8 | AlignConsecutiveDeclarations: false 9 | AlignEscapedNewlines: Left 10 | AlignOperands: true 11 | AlignTrailingComments: true 12 | AllowAllArgumentsOnNextLine: true 13 | AllowAllConstructorInitializersOnNextLine: true 14 | AllowAllParametersOfDeclarationOnNextLine: true 15 | AllowShortBlocksOnASingleLine: false 16 | AllowShortCaseLabelsOnASingleLine: false 17 | AllowShortFunctionsOnASingleLine: All 18 | AllowShortLambdasOnASingleLine: All 19 | AllowShortIfStatementsOnASingleLine: WithoutElse 20 | AllowShortLoopsOnASingleLine: true 21 | AlwaysBreakAfterDefinitionReturnType: None 22 | AlwaysBreakAfterReturnType: None 23 | AlwaysBreakBeforeMultilineStrings: true 24 | AlwaysBreakTemplateDeclarations: Yes 25 | BinPackArguments: true 26 | BinPackParameters: true 27 | BraceWrapping: 28 | AfterCaseLabel: false 29 | AfterClass: false 30 | AfterControlStatement: false 31 | AfterEnum: false 32 | AfterFunction: false 33 | AfterNamespace: false 34 | AfterObjCDeclaration: false 35 | AfterStruct: false 36 | AfterUnion: false 37 | AfterExternBlock: false 38 | BeforeCatch: false 39 | BeforeElse: false 40 | IndentBraces: false 41 | SplitEmptyFunction: true 42 | SplitEmptyRecord: true 43 | SplitEmptyNamespace: true 44 | BreakBeforeBinaryOperators: None 45 | BreakBeforeBraces: Attach 46 | BreakBeforeInheritanceComma: false 47 | BreakInheritanceList: BeforeColon 48 | BreakBeforeTernaryOperators: true 49 | BreakConstructorInitializersBeforeComma: false 50 | BreakConstructorInitializers: BeforeColon 51 | BreakAfterJavaFieldAnnotations: false 52 | BreakStringLiterals: true 53 | ColumnLimit: 80 54 | CommentPragmas: '^ IWYU pragma:' 55 | CompactNamespaces: false 56 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 57 | ConstructorInitializerIndentWidth: 4 58 | ContinuationIndentWidth: 4 59 | Cpp11BracedListStyle: true 60 | DerivePointerAlignment: true 61 | DisableFormat: false 62 | ExperimentalAutoDetectBinPacking: false 63 | FixNamespaceComments: true 64 | ForEachMacros: 65 | - foreach 66 | - Q_FOREACH 67 | - BOOST_FOREACH 68 | IncludeBlocks: Regroup 69 | IncludeCategories: 70 | - Regex: '^' 71 | Priority: 2 72 | - Regex: '^<.*\.h>' 73 | Priority: 1 74 | - Regex: '^<.*' 75 | Priority: 2 76 | - Regex: '.*' 77 | Priority: 3 78 | IncludeIsMainRegex: '([-_](test|unittest))?$' 79 | IndentCaseLabels: true 80 | IndentPPDirectives: None 81 | IndentWidth: 4 82 | IndentWrappedFunctionNames: false 83 | JavaScriptQuotes: Leave 84 | JavaScriptWrapImports: true 85 | KeepEmptyLinesAtTheStartOfBlocks: false 86 | MacroBlockBegin: '' 87 | MacroBlockEnd: '' 88 | MaxEmptyLinesToKeep: 1 89 | NamespaceIndentation: None 90 | ObjCBinPackProtocolList: Never 91 | ObjCBlockIndentWidth: 4 92 | ObjCSpaceAfterProperty: false 93 | ObjCSpaceBeforeProtocolList: true 94 | PenaltyBreakAssignment: 2 95 | PenaltyBreakBeforeFirstCallParameter: 1 96 | PenaltyBreakComment: 300 97 | PenaltyBreakFirstLessLess: 120 98 | PenaltyBreakString: 1000 99 | PenaltyBreakTemplateDeclaration: 10 100 | PenaltyExcessCharacter: 1000000 101 | PenaltyReturnTypeOnItsOwnLine: 200 102 | PointerAlignment: Left 103 | RawStringFormats: 104 | - Language: Cpp 105 | Delimiters: 106 | - cc 107 | - CC 108 | - cpp 109 | - Cpp 110 | - CPP 111 | - 'c++' 112 | - 'C++' 113 | CanonicalDelimiter: '' 114 | BasedOnStyle: google 115 | - Language: TextProto 116 | Delimiters: 117 | - pb 118 | - PB 119 | - proto 120 | - PROTO 121 | EnclosingFunctions: 122 | - EqualsProto 123 | - EquivToProto 124 | - PARSE_PARTIAL_TEXT_PROTO 125 | - PARSE_TEST_PROTO 126 | - PARSE_TEXT_PROTO 127 | - ParseTextOrDie 128 | - ParseTextProtoOrDie 129 | CanonicalDelimiter: '' 130 | BasedOnStyle: google 131 | ReflowComments: true 132 | SortIncludes: true 133 | SortUsingDeclarations: true 134 | SpaceAfterCStyleCast: false 135 | SpaceAfterLogicalNot: false 136 | SpaceAfterTemplateKeyword: true 137 | SpaceBeforeAssignmentOperators: true 138 | SpaceBeforeCpp11BracedList: false 139 | SpaceBeforeCtorInitializerColon: true 140 | SpaceBeforeInheritanceColon: true 141 | SpaceBeforeParens: ControlStatements 142 | SpaceBeforeRangeBasedForLoopColon: true 143 | SpaceInEmptyParentheses: false 144 | SpacesBeforeTrailingComments: 4 145 | SpacesInAngles: false 146 | SpacesInContainerLiterals: true 147 | SpacesInCStyleCastParentheses: false 148 | SpacesInParentheses: false 149 | SpacesInSquareBrackets: false 150 | Standard: Auto 151 | StatementMacros: 152 | - Q_UNUSED 153 | - QT_REQUIRE_VERSION 154 | TabWidth: 4 155 | UseTab: Never 156 | ... 157 | 158 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 33c17f923002a47589c09907db78349b1cd80d4f 2 | 38c929ccd385b634759e4965aef07d37031bf2d8 3 | 561167b58d19160140750b7c3b4567e00eb858b3 4 | c3c3c9efe4ac867e0853035613911d09c910031b 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: pbek 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://paypal.me/pbek"] 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build' 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - '*' 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | continue-on-error: ${{ matrix.experimental }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | # Linux: https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/ 19 | # macOS: https://download.qt.io/online/qtsdkrepository/mac_x64/desktop/ 20 | # Windows: https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/ 21 | qt-version: ['5.9.9', '5.13.2', '5.14.2', '5.15.2', '6.2.0', '6.4.2', '6.5.0', '6.6.0'] 22 | experimental: [false] 23 | exclude: 24 | # win64_mingw73 not found 25 | - os: windows-latest 26 | qt-version: '5.15.0' 27 | # 'D:\\a\\qmarkdowntextedit/Qt\\5.9.9\\mingw73_64\\bin\\qt.conf' not found 28 | - os: windows-latest 29 | qt-version: '5.9.9' 30 | - os: windows-latest 31 | qt-version: '6.2.0' 32 | - os: windows-latest 33 | qt-version: '6.4.2' 34 | - os: windows-latest 35 | qt-version: '6.5.0' 36 | - os: windows-latest 37 | qt-version: '6.6.0' 38 | include: 39 | - os: ubuntu-latest 40 | qt-version: '6.1.0' 41 | experimental: true 42 | - os: macos-latest 43 | qt-version: '6.1.0' 44 | experimental: true 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | 49 | # 50 | # Install Qt 51 | # 52 | 53 | # https://github.com/marketplace/actions/install-qt 54 | - if: contains( matrix.os, 'windows') && ( matrix.qt-version == '5.7' ) 55 | name: Install Qt 5.7 on Windows 56 | uses: jurplel/install-qt-action@v4 57 | with: 58 | version: ${{ matrix.qt-version }} 59 | arch: win32_mingw53 60 | # try mirror 61 | # mirror: 'http://mirrors.ocf.berkeley.edu/qt/' 62 | cache: true 63 | aqtversion: '==0.9.4' 64 | # py7zrversion: '==0.9.0' 65 | - if: contains( matrix.os, 'windows') && ( matrix.qt-version == '5.15.2' ) 66 | name: Install Qt 5.15.2 on Windows 67 | uses: jurplel/install-qt-action@v4 68 | with: 69 | version: ${{ matrix.qt-version }} 70 | arch: win64_mingw81 71 | cache: true 72 | - if: contains( matrix.os, 'windows') && ( matrix.qt-version != '5.7' ) && ( matrix.qt-version != '5.15.2' ) 73 | name: Install Qt on Windows 74 | uses: jurplel/install-qt-action@v4 75 | with: 76 | version: ${{ matrix.qt-version }} 77 | arch: win64_mingw73 78 | cache: true 79 | - if: false == contains( matrix.os, 'windows') && !startsWith( matrix.qt-version, '6.' ) 80 | name: Install Qt 81 | uses: jurplel/install-qt-action@v4 82 | with: 83 | version: ${{ matrix.qt-version }} 84 | cache: true 85 | - if: false == contains( matrix.os, 'windows') && startsWith( matrix.qt-version, '6.' ) 86 | name: Install Qt 87 | uses: jurplel/install-qt-action@v4 88 | with: 89 | version: ${{ matrix.qt-version }} 90 | cache: true 91 | 92 | # 93 | # Build qmarkdowntextedit 94 | # 95 | 96 | - name: Build qmarkdowntextedit 97 | run: | 98 | qmake && make 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | *.log 3 | *.dSYM 4 | *.plist 5 | *.user 6 | .directory 7 | build* 8 | build-* 9 | .idea 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: cpp 2 | 3 | os: 4 | - linux 5 | - osx 6 | 7 | branches: 8 | only: 9 | - main 10 | - testing 11 | 12 | env: 13 | matrix: 14 | - CONFIG=release 15 | #- CONFIG=debug 16 | 17 | install: 18 | - if [ "${TRAVIS_OS_NAME}" = "linux" ]; then 19 | lsb_release -a 20 | && sudo apt-add-repository -y ppa:ubuntu-toolchain-r/test 21 | && sudo apt-get -qq update 22 | && sudo apt-get -qq install g++ libc6 qt5-default qt5-qmake 23 | && export CXX="g++" 24 | && export CC="gcc" 25 | ; 26 | else 27 | brew update > /dev/null 28 | && brew install qt5 29 | && chmod -R 755 /usr/local/opt/qt5/* 30 | ; 31 | fi 32 | 33 | script: 34 | - if [ "${TRAVIS_OS_NAME}" != "linux" ]; then 35 | QTDIR="/usr/local/opt/qt5" 36 | && PATH="$QTDIR/bin:$PATH" 37 | && LDFLAGS=-L$QTDIR/lib 38 | && CPPFLAGS=-I$QTDIR/include 39 | ; 40 | fi 41 | - qmake qmarkdowntextedit.pro CONFIG+=$CONFIG 42 | - make 43 | 44 | notifications: 45 | email: 46 | recipients: 47 | - developer@bekerle.com 48 | on_success: change 49 | on_failure: change 50 | # irc: 51 | # # https://docs.travis-ci.com/user/notifications/#IRC-notification 52 | # channels: 53 | # - "chat.freenode.net#qownnotes" 54 | # template: 55 | # - "[%{commit}] %{repository} (%{branch}): %{message} | Commit message: %{commit_message} | Changes: %{compare_url} | Build details: %{build_url}" 56 | # on_success: always 57 | # on_failure: always 58 | # use_notice: true 59 | # skip_join: true 60 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.16) # Qt requires CMake 3.16 2 | project(qmarkdowntextedit LANGUAGES CXX VERSION 1.0.0) 3 | 4 | #set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") 5 | set(CMAKE_CXX_STANDARD 11) 6 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 7 | 8 | set(CMAKE_AUTOMOC ON) 9 | set(CMAKE_AUTOUIC ON) 10 | set(CMAKE_AUTORCC ON) 11 | set(CMAKE_INCLUDE_CURRENT_DIR ON) 12 | 13 | # add option to disable test executable 14 | option(QMARKDOWNTEXTEDIT_EXE "Build test executable" ON) 15 | 16 | # find qt 17 | find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) 18 | find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) 19 | find_package(Qt${QT_VERSION_MAJOR} OPTIONAL_COMPONENTS Quick) 20 | 21 | # needed for windows 22 | if(WIN32) 23 | set(INTL_LDFLAGS -lintl) 24 | endif(WIN32) 25 | 26 | # QMarkdownTextEdit library 27 | set(RC_FILES 28 | media.qrc 29 | ) 30 | 31 | # Translations arent loaded so don't include them 32 | set(TS_FILES 33 | trans/qmarkdowntextedit_de.ts 34 | trans/qmarkdowntextedit_ur.ts 35 | trans/qmarkdowntextedit_zh_CN.ts 36 | ) 37 | 38 | set(QMARKDOWNTEXTEDIT_SOURCES 39 | ${RC_FILES} 40 | linenumberarea.h # We need to keep this here, otherwise the build fails 41 | markdownhighlighter.cpp 42 | qmarkdowntextedit.cpp 43 | qownlanguagedata.cpp 44 | qownlanguagedata.h 45 | qplaintexteditsearchwidget.cpp 46 | qplaintexteditsearchwidget.ui 47 | ) 48 | set(QMARKDOWNTEXTEDIT_HEADERS 49 | markdownhighlighter.h 50 | qmarkdowntextedit.h 51 | qplaintexteditsearchwidget.h 52 | ) 53 | 54 | add_library(qmarkdowntextedit ${QMARKDOWNTEXTEDIT_SOURCES}) 55 | set_target_properties(qmarkdowntextedit PROPERTIES 56 | PUBLIC_HEADER "${QMARKDOWNTEXTEDIT_HEADERS}" 57 | ) 58 | 59 | target_link_libraries(qmarkdowntextedit PUBLIC 60 | Qt${QT_VERSION_MAJOR}::Widgets 61 | ${INTL_LDFLAGS} 62 | ) 63 | 64 | if (Qt${QT_VERSION_MAJOR}Quick_FOUND) 65 | target_link_libraries(qmarkdowntextedit PUBLIC Qt${QT_VERSION_MAJOR}::Quick) 66 | 67 | add_executable(QtQuickExample examples/qml/example.cpp examples/qml/ressources.qrc) 68 | target_link_libraries(QtQuickExample PRIVATE Qt${QT_VERSION_MAJOR}::Quick qmarkdowntextedit) 69 | endif() 70 | 71 | # QMarkdownTextEdit executable 72 | if(QMARKDOWNTEXTEDIT_EXE) 73 | set(SOURCE_FILES 74 | main.cpp 75 | mainwindow.cpp 76 | mainwindow.h 77 | mainwindow.ui 78 | ) 79 | 80 | add_executable(qmarkdowntextedit-exe ${SOURCE_FILES}) 81 | set_target_properties(qmarkdowntextedit-exe PROPERTIES OUTPUT_NAME "qmarkdowntextedit") 82 | target_link_libraries(qmarkdowntextedit-exe PRIVATE 83 | Qt${QT_VERSION_MAJOR}::Widgets 84 | ${INTL_LDFLAGS} 85 | qmarkdowntextedit 86 | ) 87 | endif() 88 | 89 | include(GNUInstallDirs) # Doesn't fail on windows 90 | 91 | # Install the lib 92 | install(TARGETS qmarkdowntextedit 93 | ARCHIVE DESTINATION lib 94 | LIBRARY DESTINATION lib 95 | RUNTIME DESTINATION bin 96 | PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} 97 | ) 98 | 99 | # Add PkgConfig config file 100 | configure_file(qmarkdowntextedit.pc.in ${CMAKE_BINARY_DIR}/qmarkdowntextedit.pc @ONLY) 101 | install(FILES ${CMAKE_BINARY_DIR}/qmarkdowntextedit.pc DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pkgconfig) 102 | 103 | # Install exe 104 | if(QMARKDOWNTEXTEDIT_EXE) 105 | install(TARGETS qmarkdowntextedit-exe DESTINATION bin) 106 | endif() 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014-2025 Patrizio Bekerle -- 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [QMarkdownTextEdit](https://github.com/pbek/qmarkdowntextedit) 2 | 3 | [![Build Status GitHub Actions](https://github.com/pbek/qmarkdowntextedit/workflows/Build/badge.svg?branch=main)](https://github.com/pbek/qmarkdowntextedit/actions) 4 | [![Build Status Linux/OS X](https://travis-ci.org/pbek/qmarkdowntextedit.svg?branch=main)](https://travis-ci.org/pbek/qmarkdowntextedit) 5 | [![Build Status Windows](https://ci.appveyor.com/api/projects/status/github/pbek/qmarkdowntextedit)](https://ci.appveyor.com/project/pbek/qmarkdowntextedit) 6 | 7 | QMarkdownTextEdit is a C++ Qt [QPlainTextEdit](http://doc.qt.io/qt-5/qplaintextedit.html) widget with [markdown](https://en.wikipedia.org/wiki/Markdown) highlighting and some other goodies. 8 | 9 | ## Widget Features 10 | 11 | - Markdown highlighting 12 | - Code syntax highlighting 13 | - Clickable links with `Ctrl + Click` 14 | - Block indent with `Tab` and `Shift + Tab` 15 | - Duplicate text with `Ctrl + Alt + Down` 16 | - Searching of text with `Ctrl + F` 17 | - Jump between search results with `Up` and `Down` 18 | - Close search field with `Escape` 19 | - Replacing of text with `Ctrl + R` 20 | - You can also replace text with regular expressions or whole words 21 | - Line numbers (Qt >= 5.5) 22 | - Very fast 23 | - And much more... 24 | 25 | ## Supported Markdown Features 26 | 27 | Commonmark compliance is enforced where possible however we are not fully Commonmark compliant yet. Following is a list of features/extensions supported by the highlighter. Please note that this is just a plaintext editor and as such, it only does the highlighting and not rendering of the markdown to HTML. 28 | 29 | | Feature | Availablity | 30 | | --------------------------------------------------------------------------------------- | ----------------------------------------------------- | 31 | | Bolds and Italics | Yes | 32 | | Lists (Unordered/Orderered) | Yes | 33 | | Links and Images
(Inline/Reference/Autolinks/E-mail) | Yes (Cannot handle nested links or complex cases yet) | 34 | | Heading (ATX and Setext) | Yes | 35 | | Codeblocks (indented and fenced)
Both backtick and tilde code fences are supported | Yes (Only fenced code block has syntax highlighting) | 36 | | Inline code | Yes | 37 | | Strikethrough | Yes | 38 | | Underline | Yes (Optional) | 39 | | Blockquotes | Yes | 40 | | Table | Yes | 41 | 42 | ## Screenshot 43 | 44 | ![Screenhot](screenshot.png) 45 | 46 | ## Usage 47 | 48 | There are multiple ways to use this. You can use the editor directly, or you can subclass it or you can just use the highlighter. 49 | 50 | ### Using the editor 51 | 52 | #### QMake 53 | 54 | - Include [qmarkdowntextedit.pri](https://github.com/pbek/qmarkdowntextedit/blob/main/qmarkdowntextedit.pri) 55 | to your project like this `include (qmarkdowntextedit/qmarkdowntextedit.pri)` 56 | - add a normal `QPlainTextEdit` to your UI and promote it to `QMarkdownTextEdit` (base class `QPlainTextEdit`) 57 | 58 | #### CMake 59 | 60 | - Include [CMakeLists.txt](https://github.com/pbek/qmarkdowntextedit/blob/main/CMakeLists.txt) 61 | to your project like this `add_subdirectory(qmarkdowntextedit)` 62 | - add a normal `QPlainTextEdit` to your UI and promote it to `QMarkdownTextEdit` (base class `QPlainTextEdit`) 63 | 64 | ### Using the highlighter only 65 | 66 | Highlighter can work with both `QPlainTextEdit` and `QTextEdit`. Example: 67 | 68 | ```cpp 69 | auto doc = ui->plainTextEdit->document(); 70 | auto *highlighter = new MarkdownHighlighter(doc); 71 | ``` 72 | 73 | ## Projects using QMarkdownTextEdit 74 | 75 | - [QOwnNotes](https://github.com/pbek/QOwnNotes) 76 | - [Notes](https://github.com/nuttyartist/notes) 77 | - [CuteMarkEd-NG](https://github.com/Waqar144/CuteMarkEd-NG) 78 | 79 | ## Disclaimer 80 | 81 | This SOFTWARE PRODUCT is provided by THE PROVIDER "as is" and "with all faults." THE PROVIDER makes no representations or warranties of any kind concerning the safety, suitability, lack of viruses, inaccuracies, typographical errors, or other harmful components of this SOFTWARE PRODUCT. 82 | 83 | There are inherent dangers in the use of any software, and you are solely responsible for determining whether this SOFTWARE PRODUCT is compatible with your equipment and other software installed on your equipment. You are also solely responsible for the protection of your equipment and backup of your data, and THE PROVIDER will not be liable for any damages you may suffer in connection with using, modifying, or distributing this SOFTWARE PRODUCT. 84 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # AppVeyor build configuration 2 | # http://www.appveyor.com/docs/build-configuration 3 | os: unstable 4 | skip_tags: true 5 | 6 | install: 7 | - set QTDIR=C:\Qt\5.10.1\mingw53_32 8 | - set PATH=%PATH%;%QTDIR%\bin;C:\MinGW\bin 9 | 10 | build_script: 11 | - qmake qmarkdowntextedit.pro -r -spec win32-g++ 12 | - mingw32-make 13 | -------------------------------------------------------------------------------- /examples/qml/example.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "markdownhighlighter.h" 5 | 6 | int main(int argc, char *argv[]) { 7 | QApplication app(argc, argv); 8 | 9 | qmlRegisterType("MarkdownHighlighter", 1, 0, 10 | "MarkdownHighlighter"); 11 | 12 | QQmlApplicationEngine engine; 13 | 14 | const QUrl url(QStringLiteral("qrc:/example.qml")); 15 | QObject::connect( 16 | &engine, &QQmlApplicationEngine::objectCreated, &app, 17 | [url](QObject *obj, const QUrl &objUrl) { 18 | if (!obj && url == objUrl) QCoreApplication::exit(-1); 19 | }, 20 | Qt::QueuedConnection); 21 | engine.load(url); 22 | 23 | return app.exec(); 24 | } 25 | -------------------------------------------------------------------------------- /examples/qml/example.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Window 2.0 3 | import MarkdownHighlighter 1.0 4 | 5 | Window { 6 | id: mainwindow 7 | width: 640 8 | height: 400 9 | visible: true 10 | title: qsTr("QtQuick Project") 11 | 12 | TextEdit { 13 | id: editor 14 | text: "# Hello world!" 15 | focus: true 16 | } 17 | 18 | MarkdownHighlighter { 19 | id: syntaxHighlighter 20 | textDocument: editor.textDocument 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/qml/ressources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | example.qml 4 | 5 | 6 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Use `just ` to run a recipe 2 | # https://just.systems/man/en/ 3 | 4 | # By default, run the `--list` command 5 | default: 6 | @just --list 7 | 8 | # Aliases 9 | 10 | alias fmt := format 11 | 12 | # Format all files 13 | [group('linter')] 14 | format: 15 | nix-shell -p treefmt libclang nodePackages.prettier shfmt nixfmt-rfc-style taplo --run treefmt 16 | 17 | # Add git commit hashes to the .git-blame-ignore-revs file 18 | [group('linter')] 19 | add-git-blame-ignore-revs: 20 | git log --pretty=format:"%H" --grep="^lint" >> .git-blame-ignore-revs 21 | sort .git-blame-ignore-revs | uniq > .git-blame-ignore-revs.tmp 22 | mv .git-blame-ignore-revs.tmp .git-blame-ignore-revs 23 | -------------------------------------------------------------------------------- /linenumberarea.h: -------------------------------------------------------------------------------- 1 | #ifndef LINENUMBERAREA_H 2 | #define LINENUMBERAREA_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "qmarkdowntextedit.h" 10 | 11 | class LineNumArea final : public QWidget { 12 | Q_OBJECT 13 | 14 | public: 15 | explicit LineNumArea(QMarkdownTextEdit *parent) 16 | : QWidget(parent), textEdit(parent) { 17 | Q_ASSERT(parent); 18 | 19 | _currentLineColor = QColor(QStringLiteral("#eef067")); 20 | _otherLinesColor = QColor(QStringLiteral("#a6a6a6")); 21 | setHidden(true); 22 | 23 | // We always use fixed font to avoid "width" issues 24 | setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); 25 | } 26 | 27 | void setCurrentLineColor(QColor color) { _currentLineColor = color; } 28 | 29 | void setOtherLineColor(QColor color) { 30 | _otherLinesColor = std::move(color); 31 | } 32 | 33 | int lineNumAreaWidth() const { 34 | if (!enabled) { 35 | return 0; 36 | } 37 | 38 | int digits = 2; 39 | int max = std::max(1, textEdit->blockCount()); 40 | while (max >= 10) { 41 | max /= 10; 42 | ++digits; 43 | } 44 | 45 | #if QT_VERSION >= 0x050B00 46 | int space = 47 | 13 + textEdit->fontMetrics().horizontalAdvance(u'9') * digits; 48 | #else 49 | int space = 50 | 13 + textEdit->fontMetrics().width(QLatin1Char('9')) * digits; 51 | #endif 52 | 53 | return space; 54 | } 55 | 56 | bool isLineNumAreaEnabled() const { return enabled; } 57 | 58 | void setLineNumAreaEnabled(bool e) { 59 | enabled = e; 60 | setHidden(!e); 61 | } 62 | 63 | QSize sizeHint() const override { return {lineNumAreaWidth(), 0}; } 64 | 65 | protected: 66 | void paintEvent(QPaintEvent *event) override { 67 | QPainter painter(this); 68 | 69 | painter.fillRect(event->rect(), 70 | palette().color(QPalette::Active, QPalette::Window)); 71 | 72 | auto block = textEdit->firstVisibleBlock(); 73 | int blockNumber = block.blockNumber(); 74 | qreal top = textEdit->blockBoundingGeometry(block) 75 | .translated(textEdit->contentOffset()) 76 | .top(); 77 | // Maybe the top is not 0? 78 | top += textEdit->viewportMargins().top(); 79 | qreal bottom = top; 80 | 81 | const QPen currentLine = _currentLineColor; 82 | const QPen otherLines = _otherLinesColor; 83 | painter.setFont(font()); 84 | 85 | while (block.isValid() && top <= event->rect().bottom()) { 86 | top = bottom; 87 | bottom = top + textEdit->blockBoundingRect(block).height(); 88 | if (block.isVisible() && bottom >= event->rect().top()) { 89 | QString number = QString::number(blockNumber + 1); 90 | 91 | auto isCurrentLine = 92 | textEdit->textCursor().blockNumber() == blockNumber; 93 | painter.setPen(isCurrentLine ? currentLine : otherLines); 94 | 95 | painter.drawText(-5, top, sizeHint().width(), 96 | textEdit->fontMetrics().height(), 97 | Qt::AlignRight, number); 98 | } 99 | 100 | block = block.next(); 101 | ++blockNumber; 102 | } 103 | } 104 | 105 | private: 106 | bool enabled = false; 107 | QMarkdownTextEdit *textEdit; 108 | QColor _currentLineColor; 109 | QColor _otherLinesColor; 110 | }; 111 | 112 | #endif // LINENUMBERAREA_H 113 | -------------------------------------------------------------------------------- /main.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #include 26 | 27 | #include "mainwindow.h" 28 | 29 | int main(int argc, char *argv[]) { 30 | QApplication a(argc, argv); 31 | MainWindow w; 32 | w.show(); 33 | 34 | return a.exec(); 35 | } 36 | -------------------------------------------------------------------------------- /mainwindow.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * mainwindow.cpp 25 | * 26 | * Example to show the QMarkdownTextEdit widget 27 | */ 28 | 29 | #include "mainwindow.h" 30 | 31 | #include "ui_mainwindow.h" 32 | 33 | MainWindow::MainWindow(QWidget *parent) 34 | : QMainWindow(parent), ui(new Ui::MainWindow) { 35 | ui->setupUi(this); 36 | } 37 | 38 | MainWindow::~MainWindow() { delete ui; } 39 | -------------------------------------------------------------------------------- /mainwindow.h: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include 28 | 29 | namespace Ui { 30 | class MainWindow; 31 | } 32 | 33 | class MainWindow : public QMainWindow { 34 | Q_OBJECT 35 | 36 | public: 37 | explicit MainWindow(QWidget *parent = nullptr); 38 | ~MainWindow(); 39 | 40 | private: 41 | Ui::MainWindow *ui; 42 | }; 43 | -------------------------------------------------------------------------------- /mainwindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 1070 10 | 839 11 | 12 | 13 | 14 | QMarkdownTextEdit 15 | 16 | 17 | 18 | 19 | 20 | 21 | QMarkdownTextEdit 22 | ============== 23 | 24 | *QMarkdownTextEdit* is a C++ Qt [QPlainTextEdit](http://doc.qt.io/qt-5/qplaintextedit.html) widget with **markdown highlighting** and some other goodies. 25 | 26 | ## Features 27 | 28 | - markdown highlighting 29 | - syntax highlighting 30 | - clickable links with `Ctrl + Click` 31 | - ~strikedout~ text and `inline code;` 32 | - block indent with `Tab` and `Shift + Tab` 33 | - duplicate text with `Ctrl + Alt + Down` 34 | - searching of text with `Ctrl + F` 35 | - jump between search results with `Up` and `Down` 36 | - close search field with `Escape` 37 | - and much more... 38 | 39 | ## References 40 | 41 | - [QOwnNotes - cross-platform open source plain-text file markdown note taking](https://www.qownnotes.org) 42 | 43 | ## Disclaimer 44 | 45 | This SOFTWARE PRODUCT is provided by THE PROVIDER "as is" and "with all faults." THE PROVIDER makes no representations or warranties of any kind concerning the safety, suitability, lack of viruses, inaccuracies, typographical errors, or other harmful components of this SOFTWARE PRODUCT. 46 | 47 | There are inherent dangers in the use of any software, and you are solely responsible for determining whether this SOFTWARE PRODUCT is compatible with your equipment and other software installed on your equipment. You are also solely responsible for the protection of your equipment and backup of your data, and THE PROVIDER will not be liable for any damages you may suffer in connection with using, modifying, or distributing this SOFTWARE PRODUCT. 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 0 58 | 0 59 | 1070 60 | 23 61 | 62 | 63 | 64 | 65 | 66 | TopToolBarArea 67 | 68 | 69 | false 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | QMarkdownTextEdit 78 | QPlainTextEdit 79 |
qmarkdowntextedit.h
80 |
81 |
82 | 83 | 84 |
85 | -------------------------------------------------------------------------------- /markdownhighlighter.h: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | * 24 | * QPlainTextEdit Markdown highlighter 25 | */ 26 | 27 | #pragma once 28 | 29 | #include 30 | #include 31 | #include 32 | 33 | #ifdef QT_QUICK_LIB 34 | #include 35 | #endif 36 | 37 | QT_BEGIN_NAMESPACE 38 | class QTextDocument; 39 | 40 | QT_END_NAMESPACE 41 | 42 | class MarkdownHighlighter : public QSyntaxHighlighter { 43 | Q_OBJECT 44 | 45 | #ifdef QT_QUICK_LIB 46 | Q_PROPERTY(QQuickTextDocument *textDocument READ textDocument WRITE 47 | setTextDocument NOTIFY textDocumentChanged) 48 | 49 | QQuickTextDocument *m_quickDocument = nullptr; 50 | 51 | signals: 52 | void textDocumentChanged(); 53 | 54 | public: 55 | inline QQuickTextDocument *textDocument() const { return m_quickDocument; }; 56 | void setTextDocument(QQuickTextDocument *textDocument) { 57 | if (!textDocument) return; 58 | m_quickDocument = textDocument; 59 | setDocument(m_quickDocument->textDocument()); 60 | Q_EMIT textDocumentChanged(); 61 | }; 62 | #endif 63 | 64 | public: 65 | enum HighlightingOption { 66 | None = 0, 67 | FullyHighlightedBlockQuote = 0x01, 68 | Underline = 0x02 69 | }; 70 | Q_DECLARE_FLAGS(HighlightingOptions, HighlightingOption) 71 | 72 | MarkdownHighlighter( 73 | QTextDocument *parent = nullptr, 74 | HighlightingOptions highlightingOptions = HighlightingOption::None); 75 | 76 | static inline QColor codeBlockBackgroundColor() { 77 | const QBrush brush = _formats[CodeBlock].background(); 78 | 79 | if (!brush.isOpaque()) { 80 | return QColor(Qt::transparent); 81 | } 82 | 83 | return brush.color(); 84 | } 85 | 86 | static constexpr inline bool isOctal(const char c) { 87 | return (c >= '0' && c <= '7'); 88 | } 89 | static constexpr inline bool isHex(const char c) { 90 | return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || 91 | (c >= 'A' && c <= 'F'); 92 | } 93 | static constexpr inline bool isCodeBlock(const int state) { 94 | return state == MarkdownHighlighter::CodeBlock || 95 | state == MarkdownHighlighter::CodeBlockTilde || 96 | state == MarkdownHighlighter::CodeBlockComment || 97 | state == MarkdownHighlighter::CodeBlockTildeComment || 98 | state >= MarkdownHighlighter::CodeCpp; 99 | } 100 | static constexpr inline bool isCodeBlockEnd(const int state) { 101 | return state == MarkdownHighlighter::CodeBlockEnd || 102 | state == MarkdownHighlighter::CodeBlockTildeEnd; 103 | } 104 | static constexpr inline bool isHeading(const int state) { 105 | return state >= H1 && state <= H6; 106 | } 107 | 108 | enum class RangeType { CodeSpan, Emphasis, Link }; 109 | 110 | QPair findPositionInRanges(MarkdownHighlighter::RangeType type, 111 | int blockNum, int pos) const; 112 | bool isPosInACodeSpan(int blockNumber, int position) const; 113 | bool isPosInALink(int blockNumber, int position) const; 114 | QPair getSpanRange(RangeType rangeType, int blockNumber, 115 | int position) const; 116 | 117 | // we used some predefined numbers here to be compatible with 118 | // the peg-Markdown parser 119 | enum HighlighterState { 120 | NoState = -1, 121 | Link = 0, 122 | Image = 3, 123 | CodeBlock, 124 | CodeBlockComment, 125 | Italic = 7, 126 | Bold, 127 | List, 128 | Comment = 11, 129 | H1, 130 | H2, 131 | H3, 132 | H4, 133 | H5, 134 | H6, 135 | BlockQuote, 136 | HorizontalRuler = 21, 137 | Table, 138 | InlineCodeBlock, 139 | MaskedSyntax, 140 | CurrentLineBackgroundColor, 141 | BrokenLink, 142 | FrontmatterBlock, 143 | TrailingSpace, 144 | CheckBoxUnChecked, 145 | CheckBoxChecked, 146 | StUnderline, 147 | 148 | // code highlighting 149 | CodeKeyWord = 1000, 150 | CodeString = 1001, 151 | CodeComment = 1002, 152 | CodeType = 1003, 153 | CodeOther = 1004, 154 | CodeNumLiteral = 1005, 155 | CodeBuiltIn = 1006, 156 | 157 | // internal 158 | CodeBlockIndented = 96, 159 | CodeBlockTildeEnd = 97, 160 | CodeBlockTilde = 98, 161 | CodeBlockTildeComment, 162 | CodeBlockEnd = 100, 163 | HeadlineEnd, 164 | FrontmatterBlockEnd, 165 | 166 | // languages 167 | /********* 168 | * When adding a language make sure that its value is a multiple of 2 169 | * This is because we use the next number as comment for that language 170 | * In case the language doesn't support multiline comments in the 171 | * traditional C++ sense, leave the next value empty. Otherwise mark the 172 | * next value as comment for that language. e.g CodeCpp = 200 173 | * CodeCppComment = 201 174 | */ 175 | CodeCpp = 200, 176 | CodeCppComment = 201, 177 | CodeJs = 202, 178 | CodeJsComment = 203, 179 | CodeC = 204, 180 | CodeCComment = 205, 181 | CodeBash = 206, 182 | CodePHP = 208, 183 | CodePHPComment = 209, 184 | CodeQML = 210, 185 | CodeQMLComment = 211, 186 | CodePython = 212, 187 | CodeRust = 214, 188 | CodeRustComment = 215, 189 | CodeJava = 216, 190 | CodeJavaComment = 217, 191 | CodeCSharp = 218, 192 | CodeCSharpComment = 219, 193 | CodeGo = 220, 194 | CodeGoComment = 221, 195 | CodeV = 222, 196 | CodeVComment = 223, 197 | CodeSQL = 224, 198 | CodeSQLComment = 225, 199 | CodeJSON = 226, 200 | CodeXML = 228, 201 | CodeCSS = 230, 202 | CodeCSSComment = 231, 203 | CodeTypeScript = 232, 204 | CodeTypeScriptComment = 233, 205 | CodeYAML = 234, 206 | CodeINI = 236, 207 | CodeTaggerScript = 238, 208 | CodeVex = 240, 209 | CodeVexComment = 241, 210 | CodeCMake = 242, 211 | CodeMake = 244, 212 | CodeNix = 246, 213 | CodeForth = 248, 214 | CodeForthComment = 249, 215 | CodeSystemVerilog = 250, 216 | CodeSystemVerilogComment = 251, 217 | CodeGDScript = 252, 218 | CodeTOML = 254, 219 | CodeTOMLString = 255 220 | }; 221 | Q_ENUM(HighlighterState) 222 | 223 | static void setTextFormats( 224 | QHash formats); 225 | static void setTextFormat(HighlighterState state, QTextCharFormat format); 226 | void clearDirtyBlocks(); 227 | void setHighlightingOptions(const HighlightingOptions options); 228 | void initHighlightingRules(); 229 | 230 | Q_SIGNALS: 231 | void highlightingFinished(); 232 | 233 | protected Q_SLOTS: 234 | void timerTick(); 235 | 236 | protected: 237 | struct HighlightingRule { 238 | explicit HighlightingRule(const HighlighterState state_) 239 | : state(state_) {} 240 | HighlightingRule() = default; 241 | 242 | QRegularExpression pattern; 243 | QString shouldContain; 244 | HighlighterState state = NoState; 245 | uint8_t capturingGroup = 0; 246 | uint8_t maskedGroup = 0; 247 | }; 248 | struct InlineRange { 249 | int begin; 250 | int end; 251 | RangeType type; 252 | InlineRange() = default; 253 | InlineRange(int begin_, int end_, RangeType type_) 254 | : begin{begin_}, end{end_}, type{type_} {} 255 | }; 256 | 257 | void highlightBlock(const QString &text) override; 258 | 259 | static void initTextFormats(int defaultFontSize = 12); 260 | 261 | static void initCodeLangs(); 262 | 263 | void highlightMarkdown(const QString &text); 264 | 265 | void formatAndMaskRemaining(int formatBegin, int formatLength, 266 | int beginningText, int endText, 267 | const QTextCharFormat &format); 268 | 269 | /****************************** 270 | * BLOCK LEVEL FUNCTIONS 271 | ******************************/ 272 | 273 | void highlightHeadline(const QString &text); 274 | 275 | void highlightSubHeadline(const QString &text, HighlighterState state); 276 | 277 | void highlightAdditionalRules(const QVector &rules, 278 | const QString &text); 279 | 280 | void highlightFrontmatterBlock(const QString &text); 281 | 282 | void highlightCommentBlock(const QString &text); 283 | 284 | void highlightThematicBreak(const QString &text); 285 | 286 | void highlightLists(const QString &text); 287 | 288 | void highlightCheckbox(const QString &text, int curPos); 289 | 290 | /****************************** 291 | * INLINE FUNCTIONS 292 | ******************************/ 293 | 294 | void highlightInlineRules(const QString &text); 295 | 296 | int highlightInlineSpans(const QString &text, int currentPos, 297 | const QChar c); 298 | 299 | void highlightEmAndStrong(const QString &text, const int pos); 300 | 301 | Q_REQUIRED_RESULT int highlightInlineComment(const QString &text, int pos); 302 | 303 | int highlightLinkOrImage(const QString &text, int startIndex); 304 | 305 | void setHeadingStyles(MarkdownHighlighter::HighlighterState rule, 306 | const QRegularExpressionMatch &match, 307 | const int capturedGroup); 308 | 309 | /****************************** 310 | * CODE HIGHLIGHTING FUNCTIONS 311 | ******************************/ 312 | 313 | void highlightIndentedCodeBlock(const QString &text); 314 | 315 | void highlightCodeFence(const QString &text); 316 | 317 | void highlightCodeBlock(const QString &text, 318 | const QString &opener = QStringLiteral("```")); 319 | 320 | void highlightSyntax(const QString &text); 321 | 322 | Q_REQUIRED_RESULT int highlightNumericLiterals(const QString &text, int i); 323 | 324 | Q_REQUIRED_RESULT int highlightStringLiterals(QChar strType, 325 | const QString &text, int i); 326 | 327 | void ymlHighlighter(const QString &text); 328 | 329 | void iniHighlighter(const QString &text); 330 | 331 | void cssHighlighter(const QString &text); 332 | 333 | void xmlHighlighter(const QString &text); 334 | 335 | void makeHighlighter(const QString &text); 336 | 337 | void forthHighlighter(const QString &text); 338 | void taggerScriptHighlighter(const QString &text); 339 | 340 | void gdscriptHighlighter(const QString &text); 341 | void sqlHighlighter(const QString &text); 342 | void tomlHighlighter(const QString &text); 343 | 344 | void addDirtyBlock(const QTextBlock &block); 345 | 346 | void reHighlightDirtyBlocks(); 347 | 348 | bool _highlightingFinished; 349 | HighlightingOptions _highlightingOptions; 350 | QTimer *_timer; 351 | QVector _dirtyTextBlocks; 352 | QVector> _linkRanges; 353 | 354 | QHash> _ranges; 355 | 356 | static QVector _highlightingRules; 357 | static QHash _formats; 358 | static QHash _langStringToEnum; 359 | static constexpr int tildeOffset = 300; 360 | }; 361 | -------------------------------------------------------------------------------- /media.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | media/window-close.svg 4 | media/go-top.svg 5 | media/go-bottom.svg 6 | media/edit-find-replace.svg 7 | media/format-text-superscript.svg 8 | 9 | 10 | -------------------------------------------------------------------------------- /media/edit-find-replace.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/format-text-superscript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/go-bottom.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/go-top.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/window-close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /old_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/old_screenshot.png -------------------------------------------------------------------------------- /qmarkdowntextedit-app.pro: -------------------------------------------------------------------------------- 1 | TARGET = QMarkdownTextedit 2 | TEMPLATE = app 3 | QT += core gui widgets 4 | CONFIG += c++11 5 | 6 | SOURCES = main.cpp mainwindow.cpp 7 | HEADERS = mainwindow.h 8 | FORMS = mainwindow.ui 9 | 10 | LIBS += -lQMarkdownTextedit -L$$OUT_PWD 11 | 12 | win32: LIBS += -L$$OUT_PWD/release -L$$OUT_PWD/debug 13 | 14 | target.path = $$[QT_INSTALL_BINS] 15 | 16 | INSTALLS += target 17 | 18 | -------------------------------------------------------------------------------- /qmarkdowntextedit-headers.pri: -------------------------------------------------------------------------------- 1 | INCLUDEPATH += $$PWD/ 2 | 3 | HEADERS += \ 4 | $$PWD/linenumberarea.h \ 5 | $$PWD/markdownhighlighter.h \ 6 | $$PWD/qmarkdowntextedit.h \ 7 | $$PWD/qownlanguagedata.h \ 8 | $$PWD/qplaintexteditsearchwidget.h 9 | -------------------------------------------------------------------------------- /qmarkdowntextedit-lib.pro: -------------------------------------------------------------------------------- 1 | TARGET = QMarkdownTextedit 2 | TEMPLATE = lib 3 | QT += core gui widgets 4 | CONFIG += c++11 create_prl no_install_prl create_pc 5 | 6 | include(qmarkdowntextedit.pri) 7 | 8 | TRANSLATIONS += trans/qmarkdowntextedit_de.ts \ 9 | trans/qmarkdowntextedit_zh_CN.ts \ 10 | trans/qmarkdowntextedit_es.ts 11 | 12 | isEmpty(PREFIX):PREFIX=$$[QT_INSTALL_PREFIX] 13 | isEmpty(LIBDIR):LIBDIR=$$[QT_INSTALL_LIBS] 14 | isEmpty(HEADERDIR):HEADERDIR=$${PREFIX}/include/$$TARGET/ 15 | isEmpty(DSRDIR):DSRDIR=$${PREFIX}/share/$$TARGET 16 | 17 | target.path = $${LIBDIR} 18 | 19 | headers.files = $$HEADERS 20 | headers.path = $${HEADERDIR} 21 | 22 | license.files = LICENSE 23 | license.path = $${DSRDIR}/licenses/ 24 | 25 | trans.files = trans/*.qm 26 | trans.path = $${DSRDIR}/translations/ 27 | 28 | QMAKE_PKGCONFIG_NAME = QMarkdownTextedit 29 | QMAKE_PKGCONFIG_DESCRIPTION = C++ Qt QPlainTextEdit widget with markdown highlighting and some other goodies 30 | QMAKE_PKGCONFIG_INCDIR = $${headers.path} 31 | QMAKE_PKGCONFIG_LIBDIR = $${LIBDIR} 32 | QMAKE_PKGCONFIG_DESTDIR = pkgconfig 33 | 34 | INSTALLS += target license headers trans 35 | -------------------------------------------------------------------------------- /qmarkdowntextedit-sources.pri: -------------------------------------------------------------------------------- 1 | INCLUDEPATH += $$PWD/ 2 | 3 | QT += gui 4 | greaterThan(QT_MAJOR_VERSION, 4): QT += widgets 5 | 6 | SOURCES += \ 7 | $$PWD/markdownhighlighter.cpp \ 8 | $$PWD/qmarkdowntextedit.cpp \ 9 | $$PWD/qownlanguagedata.cpp \ 10 | $$PWD/qplaintexteditsearchwidget.cpp 11 | 12 | RESOURCES += \ 13 | $$PWD/media.qrc 14 | 15 | FORMS += $$PWD/qplaintexteditsearchwidget.ui 16 | -------------------------------------------------------------------------------- /qmarkdowntextedit.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #include "qmarkdowntextedit.h" 26 | 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include 39 | #include 40 | #include 41 | #include 42 | #include 43 | #include 44 | 45 | #include "linenumberarea.h" 46 | #include "markdownhighlighter.h" 47 | 48 | static const QByteArray _openingCharacters = QByteArrayLiteral("([{<*\"'_~"); 49 | static const QByteArray _closingCharacters = QByteArrayLiteral(")]}>*\"'_~"); 50 | 51 | QMarkdownTextEdit::QMarkdownTextEdit(QWidget *parent, bool initHighlighter) 52 | : QPlainTextEdit(parent) { 53 | installEventFilter(this); 54 | viewport()->installEventFilter(this); 55 | _autoTextOptions = AutoTextOption::BracketClosing; 56 | 57 | _lineNumArea = new LineNumArea(this); 58 | updateLineNumberAreaWidth(0); 59 | 60 | // Markdown highlighting is enabled by default 61 | _highlightingEnabled = initHighlighter; 62 | if (initHighlighter) { 63 | _highlighter = new MarkdownHighlighter(document()); 64 | } 65 | 66 | QFont font = this->font(); 67 | 68 | // set the tab stop to the width of 4 spaces in the editor 69 | constexpr int tabStop = 4; 70 | QFontMetrics metrics(font); 71 | 72 | #if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) 73 | setTabStopWidth(tabStop * metrics.width(' ')); 74 | #else 75 | setTabStopDistance(tabStop * metrics.horizontalAdvance(QLatin1Char(' '))); 76 | #endif 77 | 78 | // add shortcuts for duplicating text 79 | // new QShortcut( QKeySequence( "Ctrl+D" ), this, SLOT( duplicateText() ) 80 | // ); new QShortcut( QKeySequence( "Ctrl+Alt+Down" ), this, SLOT( 81 | // duplicateText() ) ); 82 | 83 | // add a layout to the widget 84 | auto *layout = new QVBoxLayout(this); 85 | layout->setContentsMargins(0, 0, 0, 0); 86 | layout->addStretch(); 87 | this->setLayout(layout); 88 | 89 | // add the hidden search widget 90 | _searchWidget = new QPlainTextEditSearchWidget(this); 91 | this->layout()->addWidget(_searchWidget); 92 | 93 | connect(this, &QPlainTextEdit::textChanged, this, 94 | &QMarkdownTextEdit::adjustRightMargin); 95 | connect(this, &QPlainTextEdit::cursorPositionChanged, this, 96 | &QMarkdownTextEdit::centerTheCursor); 97 | connect(verticalScrollBar(), &QScrollBar::valueChanged, this, 98 | [this](int) { _lineNumArea->update(); }); 99 | connect(this, &QPlainTextEdit::cursorPositionChanged, this, [this]() { 100 | _lineNumArea->update(); 101 | 102 | auto oldArea = blockBoundingGeometry(_textCursor.block()) 103 | .translated(contentOffset()); 104 | _textCursor = textCursor(); 105 | auto newArea = blockBoundingGeometry(_textCursor.block()) 106 | .translated(contentOffset()); 107 | auto areaToUpdate = oldArea | newArea; 108 | viewport()->update(areaToUpdate.toRect()); 109 | }); 110 | connect(document(), &QTextDocument::blockCountChanged, this, 111 | &QMarkdownTextEdit::updateLineNumberAreaWidth); 112 | connect(this, &QPlainTextEdit::updateRequest, this, 113 | &QMarkdownTextEdit::updateLineNumberArea); 114 | 115 | updateSettings(); 116 | 117 | // workaround for disabled signals up initialization 118 | QTimer::singleShot(300, this, &QMarkdownTextEdit::adjustRightMargin); 119 | } 120 | 121 | void QMarkdownTextEdit::setLineNumbersCurrentLineColor(QColor color) { 122 | _lineNumArea->setCurrentLineColor(std::move(color)); 123 | } 124 | 125 | void QMarkdownTextEdit::setLineNumbersOtherLineColor(QColor color) { 126 | _lineNumArea->setOtherLineColor(std::move(color)); 127 | } 128 | 129 | void QMarkdownTextEdit::setSearchWidgetDebounceDelay(uint debounceDelay) { 130 | _debounceDelay = debounceDelay; 131 | searchWidget()->setDebounceDelay(_debounceDelay); 132 | } 133 | 134 | void QMarkdownTextEdit::setHighlightCurrentLine(bool set) { 135 | _highlightCurrentLine = set; 136 | } 137 | 138 | bool QMarkdownTextEdit::highlightCurrentLine() { return _highlightCurrentLine; } 139 | 140 | void QMarkdownTextEdit::setCurrentLineHighlightColor(const QColor &color) { 141 | _currentLineHighlightColor = color; 142 | } 143 | 144 | QColor QMarkdownTextEdit::currentLineHighlightColor() { 145 | return _currentLineHighlightColor; 146 | } 147 | 148 | /** 149 | * Enables or disables the Markdown highlighting 150 | * 151 | * @param enabled 152 | */ 153 | void QMarkdownTextEdit::setHighlightingEnabled(bool enabled) { 154 | if (_highlightingEnabled == enabled || _highlighter == nullptr) { 155 | return; 156 | } 157 | 158 | _highlightingEnabled = enabled; 159 | _highlighter->setDocument(enabled ? document() : Q_NULLPTR); 160 | 161 | if (enabled) { 162 | _highlighter->rehighlight(); 163 | } 164 | } 165 | 166 | /** 167 | * @brief Returns if highlighting is enabled 168 | * @return Returns true if highlighting is enabled, otherwise false 169 | */ 170 | bool QMarkdownTextEdit::highlightingEnabled() const { 171 | return _highlightingEnabled && _highlighter != nullptr; 172 | } 173 | 174 | /** 175 | * Leave a little space on the right side if the document is too long, so 176 | * that the search buttons don't get visually blocked by the scroll bar 177 | */ 178 | void QMarkdownTextEdit::adjustRightMargin() { 179 | QMargins margins = layout()->contentsMargins(); 180 | const int rightMargin = 181 | document()->size().height() > viewport()->size().height() ? 24 : 0; 182 | margins.setRight(rightMargin); 183 | layout()->setContentsMargins(margins); 184 | } 185 | 186 | bool QMarkdownTextEdit::eventFilter(QObject *obj, QEvent *event) { 187 | // qDebug() << event->type(); 188 | if (event->type() == QEvent::HoverMove) { 189 | auto *mouseEvent = static_cast(event); 190 | 191 | QWidget *viewPort = this->viewport(); 192 | // toggle cursor when control key has been pressed or released 193 | viewPort->setCursor( 194 | mouseEvent->modifiers().testFlag(Qt::ControlModifier) 195 | ? Qt::PointingHandCursor 196 | : Qt::IBeamCursor); 197 | } else if (event->type() == QEvent::KeyPress) { 198 | auto *keyEvent = static_cast(event); 199 | 200 | // set cursor to pointing hand if control key was pressed 201 | if (keyEvent->modifiers().testFlag(Qt::ControlModifier)) { 202 | QWidget *viewPort = this->viewport(); 203 | viewPort->setCursor(Qt::PointingHandCursor); 204 | } 205 | 206 | // disallow keys if text edit hasn't focus 207 | if (!this->hasFocus()) { 208 | return true; 209 | } 210 | 211 | if ((keyEvent->key() == Qt::Key_Escape) && _searchWidget->isVisible()) { 212 | _searchWidget->deactivate(); 213 | return true; 214 | } else if (keyEvent->key() == Qt::Key_Insert && 215 | keyEvent->modifiers().testFlag(Qt::NoModifier)) { 216 | setOverwriteMode(!overwriteMode()); 217 | 218 | // This solves a UI glitch if the visual cursor was not properly 219 | // updated when characters have different widths 220 | QTextCursor cursor = this->textCursor(); 221 | cursor.movePosition(QTextCursor::Right); 222 | setTextCursor(cursor); 223 | cursor.movePosition(QTextCursor::Left); 224 | setTextCursor(cursor); 225 | 226 | return false; 227 | } else if ((keyEvent->key() == Qt::Key_Tab) || 228 | (keyEvent->key() == Qt::Key_Backtab)) { 229 | // handle entered tab and reverse tab keys 230 | return handleTabEntered(keyEvent->key() == Qt::Key_Backtab); 231 | } else if ((keyEvent->key() == Qt::Key_F) && 232 | keyEvent->modifiers().testFlag(Qt::ControlModifier)) { 233 | _searchWidget->activate(); 234 | return true; 235 | } else if ((keyEvent->key() == Qt::Key_R) && 236 | keyEvent->modifiers().testFlag(Qt::ControlModifier)) { 237 | _searchWidget->activateReplace(); 238 | return true; 239 | // } else if (keyEvent->key() == Qt::Key_Delete) { 240 | } else if (keyEvent->key() == Qt::Key_Backspace) { 241 | return handleBackspaceEntered(); 242 | } else if (keyEvent->key() == Qt::Key_Asterisk) { 243 | return handleBracketClosing(QLatin1Char('*')); 244 | } else if (keyEvent->key() == Qt::Key_QuoteDbl) { 245 | return quotationMarkCheck(QLatin1Char('"')); 246 | // apostrophe bracket closing is temporary disabled because 247 | // apostrophes are used in different contexts 248 | // } else if (keyEvent->key() == Qt::Key_Apostrophe) { 249 | // return handleBracketClosing("'"); 250 | // underline bracket closing is temporary disabled because 251 | // underlines are used in different contexts 252 | // } else if (keyEvent->key() == Qt::Key_Underscore) { 253 | // return handleBracketClosing("_"); 254 | } else if (keyEvent->key() == Qt::Key_QuoteLeft) { 255 | return quotationMarkCheck(QLatin1Char('`')); 256 | } else if (keyEvent->key() == Qt::Key_AsciiTilde) { 257 | return handleBracketClosing(QLatin1Char('~')); 258 | #ifdef Q_OS_MAC 259 | } else if (keyEvent->modifiers().testFlag(Qt::AltModifier) && 260 | keyEvent->key() == Qt::Key_ParenLeft) { 261 | // bracket closing for US keyboard on macOS 262 | return handleBracketClosing(QLatin1Char('{'), QLatin1Char('}')); 263 | #endif 264 | } else if (keyEvent->key() == Qt::Key_ParenLeft) { 265 | return handleBracketClosing(QLatin1Char('('), QLatin1Char(')')); 266 | } else if (keyEvent->key() == Qt::Key_BraceLeft) { 267 | return handleBracketClosing(QLatin1Char('{'), QLatin1Char('}')); 268 | } else if (keyEvent->key() == Qt::Key_BracketLeft) { 269 | return handleBracketClosing(QLatin1Char('['), QLatin1Char(']')); 270 | } else if (keyEvent->key() == Qt::Key_Less) { 271 | return handleBracketClosing(QLatin1Char('<'), QLatin1Char('>')); 272 | #ifdef Q_OS_MAC 273 | } else if (keyEvent->modifiers().testFlag(Qt::AltModifier) && 274 | keyEvent->key() == Qt::Key_ParenRight) { 275 | // bracket closing for US keyboard on macOS 276 | return bracketClosingCheck(QLatin1Char('{'), QLatin1Char('}')); 277 | #endif 278 | } else if (keyEvent->key() == Qt::Key_ParenRight) { 279 | return bracketClosingCheck(QLatin1Char('('), QLatin1Char(')')); 280 | } else if (keyEvent->key() == Qt::Key_BraceRight) { 281 | return bracketClosingCheck(QLatin1Char('{'), QLatin1Char('}')); 282 | } else if (keyEvent->key() == Qt::Key_BracketRight) { 283 | return bracketClosingCheck(QLatin1Char('['), QLatin1Char(']')); 284 | } else if (keyEvent->key() == Qt::Key_Greater) { 285 | return bracketClosingCheck(QLatin1Char('<'), QLatin1Char('>')); 286 | } else if ((keyEvent->key() == Qt::Key_Return || 287 | keyEvent->key() == Qt::Key_Enter) && 288 | !isReadOnly() && 289 | keyEvent->modifiers().testFlag(Qt::ShiftModifier)) { 290 | QTextCursor cursor = this->textCursor(); 291 | cursor.insertText(" \n"); 292 | return true; 293 | } else if ((keyEvent->key() == Qt::Key_Return || 294 | keyEvent->key() == Qt::Key_Enter) && 295 | !isReadOnly() && 296 | keyEvent->modifiers().testFlag(Qt::ControlModifier)) { 297 | QTextCursor cursor = this->textCursor(); 298 | cursor.movePosition(QTextCursor::EndOfBlock); 299 | cursor.insertText(QStringLiteral("\n")); 300 | setTextCursor(cursor); 301 | return true; 302 | } else if (keyEvent == QKeySequence::Copy || 303 | keyEvent == QKeySequence::Cut) { 304 | QTextCursor cursor = this->textCursor(); 305 | if (!cursor.hasSelection()) { 306 | QString text; 307 | if (cursor.block().length() <= 1) // no content 308 | text = "\n"; 309 | else { 310 | // cursor.select(QTextCursor::BlockUnderCursor); // 311 | // negative, it will include the previous paragraph 312 | // separator 313 | cursor.movePosition(QTextCursor::StartOfBlock); 314 | cursor.movePosition(QTextCursor::EndOfBlock, 315 | QTextCursor::KeepAnchor); 316 | text = cursor.selectedText(); 317 | if (!cursor.atEnd()) { 318 | text += "\n"; 319 | // this is the paragraph separator 320 | cursor.movePosition(QTextCursor::NextCharacter, 321 | QTextCursor::KeepAnchor, 1); 322 | } 323 | } 324 | if (keyEvent == QKeySequence::Cut) { 325 | if (!cursor.atEnd() && text == "\n") 326 | cursor.deletePreviousChar(); 327 | else 328 | cursor.removeSelectedText(); 329 | cursor.movePosition(QTextCursor::StartOfBlock); 330 | setTextCursor(cursor); 331 | } 332 | qApp->clipboard()->setText(text); 333 | return true; 334 | } 335 | } else if ((keyEvent->key() == Qt::Key_Down) && 336 | keyEvent->modifiers().testFlag(Qt::ControlModifier) && 337 | keyEvent->modifiers().testFlag(Qt::AltModifier)) { 338 | // duplicate text with `Ctrl + Alt + Down` 339 | duplicateText(); 340 | return true; 341 | #ifndef Q_OS_MAC 342 | } else if ((keyEvent->key() == Qt::Key_Down) && 343 | keyEvent->modifiers().testFlag(Qt::ControlModifier) && 344 | !keyEvent->modifiers().testFlag(Qt::ShiftModifier)) { 345 | // scroll the page down 346 | auto *scrollBar = verticalScrollBar(); 347 | scrollBar->setSliderPosition(scrollBar->sliderPosition() + 1); 348 | return true; 349 | } else if ((keyEvent->key() == Qt::Key_Up) && 350 | keyEvent->modifiers().testFlag(Qt::ControlModifier) && 351 | !keyEvent->modifiers().testFlag(Qt::ShiftModifier)) { 352 | // scroll the page up 353 | auto *scrollBar = verticalScrollBar(); 354 | scrollBar->setSliderPosition(scrollBar->sliderPosition() - 1); 355 | return true; 356 | #endif 357 | } else if ((keyEvent->key() == Qt::Key_Down) && 358 | keyEvent->modifiers().testFlag(Qt::NoModifier)) { 359 | // if you are in the last line and press cursor down the cursor will 360 | // jump to the end of the line 361 | QTextCursor cursor = textCursor(); 362 | if (cursor.position() >= document()->lastBlock().position()) { 363 | cursor.movePosition(QTextCursor::EndOfLine); 364 | 365 | // check if we are really in the last line, not only in 366 | // the last block 367 | if (cursor.atBlockEnd()) { 368 | setTextCursor(cursor); 369 | } 370 | } 371 | return QPlainTextEdit::eventFilter(obj, event); 372 | } else if ((keyEvent->key() == Qt::Key_Up) && 373 | keyEvent->modifiers().testFlag(Qt::NoModifier)) { 374 | // if you are in the first line and press cursor up the cursor will 375 | // jump to the start of the line 376 | QTextCursor cursor = textCursor(); 377 | QTextBlock block = document()->firstBlock(); 378 | int endOfFirstLinePos = block.position() + block.length(); 379 | 380 | if (cursor.position() <= endOfFirstLinePos) { 381 | cursor.movePosition(QTextCursor::StartOfLine); 382 | 383 | // check if we are really in the first line, not only in 384 | // the first block 385 | if (cursor.atBlockStart()) { 386 | setTextCursor(cursor); 387 | } 388 | } 389 | return QPlainTextEdit::eventFilter(obj, event); 390 | } else if (keyEvent->key() == Qt::Key_Return || 391 | keyEvent->key() == Qt::Key_Enter) { 392 | return handleReturnEntered(); 393 | } else if ((keyEvent->key() == Qt::Key_F3)) { 394 | _searchWidget->doSearch( 395 | !keyEvent->modifiers().testFlag(Qt::ShiftModifier)); 396 | return true; 397 | } else if ((keyEvent->key() == Qt::Key_Z) && 398 | (keyEvent->modifiers().testFlag(Qt::ControlModifier)) && 399 | !(keyEvent->modifiers().testFlag(Qt::ShiftModifier))) { 400 | undo(); 401 | return true; 402 | } else if ((keyEvent->key() == Qt::Key_Down) && 403 | (keyEvent->modifiers().testFlag(Qt::ControlModifier)) && 404 | (keyEvent->modifiers().testFlag(Qt::ShiftModifier))) { 405 | moveTextUpDown(false); 406 | return true; 407 | } else if ((keyEvent->key() == Qt::Key_Up) && 408 | (keyEvent->modifiers().testFlag(Qt::ControlModifier)) && 409 | (keyEvent->modifiers().testFlag(Qt::ShiftModifier))) { 410 | moveTextUpDown(true); 411 | return true; 412 | #ifdef Q_OS_MAC 413 | // https://github.com/pbek/QOwnNotes/issues/1593 414 | // https://github.com/pbek/QOwnNotes/issues/2643 415 | } else if (keyEvent->key() == Qt::Key_Home) { 416 | QTextCursor cursor = textCursor(); 417 | // Meta is Control on macOS 418 | cursor.movePosition( 419 | keyEvent->modifiers().testFlag(Qt::MetaModifier) 420 | ? QTextCursor::Start 421 | : QTextCursor::StartOfLine, 422 | keyEvent->modifiers().testFlag(Qt::ShiftModifier) 423 | ? QTextCursor::KeepAnchor 424 | : QTextCursor::MoveAnchor); 425 | this->setTextCursor(cursor); 426 | return true; 427 | } else if (keyEvent->key() == Qt::Key_End) { 428 | QTextCursor cursor = textCursor(); 429 | // Meta is Control on macOS 430 | cursor.movePosition( 431 | keyEvent->modifiers().testFlag(Qt::MetaModifier) 432 | ? QTextCursor::End 433 | : QTextCursor::EndOfLine, 434 | keyEvent->modifiers().testFlag(Qt::ShiftModifier) 435 | ? QTextCursor::KeepAnchor 436 | : QTextCursor::MoveAnchor); 437 | this->setTextCursor(cursor); 438 | return true; 439 | #endif 440 | } 441 | 442 | return QPlainTextEdit::eventFilter(obj, event); 443 | } else if (event->type() == QEvent::KeyRelease) { 444 | auto *keyEvent = static_cast(event); 445 | 446 | // reset cursor if control key was released 447 | if (keyEvent->key() == Qt::Key_Control) { 448 | resetMouseCursor(); 449 | } 450 | 451 | return QPlainTextEdit::eventFilter(obj, event); 452 | } else if (event->type() == QEvent::MouseButtonRelease) { 453 | _mouseButtonDown = false; 454 | auto *mouseEvent = static_cast(event); 455 | 456 | // track `Ctrl + Click` in the text edit 457 | if ((obj == this->viewport()) && 458 | (mouseEvent->button() == Qt::LeftButton) && 459 | (QGuiApplication::keyboardModifiers() == Qt::ExtraButton24)) { 460 | // open the link (if any) at the current position 461 | // in the noteTextEdit 462 | openLinkAtCursorPosition(); 463 | return true; 464 | } 465 | } else if (event->type() == QEvent::MouseButtonPress) { 466 | _mouseButtonDown = true; 467 | } else if (event->type() == QEvent::MouseButtonDblClick) { 468 | _mouseButtonDown = true; 469 | } else if (event->type() == QEvent::Wheel) { 470 | auto *wheel = dynamic_cast(event); 471 | 472 | // emit zoom signals 473 | if (wheel->modifiers() == Qt::ControlModifier) { 474 | if (wheel->angleDelta().y() > 0) { 475 | Q_EMIT zoomIn(); 476 | } else { 477 | Q_EMIT zoomOut(); 478 | } 479 | 480 | return true; 481 | } 482 | } 483 | 484 | return QPlainTextEdit::eventFilter(obj, event); 485 | } 486 | 487 | void QMarkdownTextEdit::centerTheCursor() { 488 | if (_mouseButtonDown || !_centerCursor) { 489 | return; 490 | } 491 | 492 | // Centers the cursor every time, but not on the top and bottom, 493 | // bottom is done by setCenterOnScroll() in updateSettings() 494 | centerCursor(); 495 | 496 | /* 497 | QRect cursor = cursorRect(); 498 | QRect vp = viewport()->rect(); 499 | 500 | qDebug() << __func__ << " - 'cursor.top': " << cursor.top(); 501 | qDebug() << __func__ << " - 'cursor.bottom': " << cursor.bottom(); 502 | qDebug() << __func__ << " - 'vp': " << vp.bottom(); 503 | 504 | int bottom = 0; 505 | int top = 0; 506 | 507 | qDebug() << __func__ << " - 'viewportMargins().top()': " 508 | << viewportMargins().top(); 509 | 510 | qDebug() << __func__ << " - 'viewportMargins().bottom()': " 511 | << viewportMargins().bottom(); 512 | 513 | int vpBottom = viewportMargins().top() + viewportMargins().bottom() + 514 | vp.bottom(); int vpCenter = vpBottom / 2; int cBottom = cursor.bottom() + 515 | viewportMargins().top(); 516 | 517 | qDebug() << __func__ << " - 'vpBottom': " << vpBottom; 518 | qDebug() << __func__ << " - 'vpCenter': " << vpCenter; 519 | qDebug() << __func__ << " - 'cBottom': " << cBottom; 520 | 521 | 522 | if (cBottom >= vpCenter) { 523 | bottom = cBottom + viewportMargins().top() / 2 + 524 | viewportMargins().bottom() / 2 - (vp.bottom() / 2); 525 | // bottom = cBottom - (vp.bottom() / 2); 526 | // bottom *= 1.5; 527 | } 528 | 529 | // setStyleSheet(QString("QPlainTextEdit {padding-bottom: 530 | %1px;}").arg(QString::number(bottom))); 531 | 532 | // if (cursor.top() < (vp.bottom() / 2)) { 533 | // top = (vp.bottom() / 2) - cursor.top() + viewportMargins().top() / 534 | 2 + viewportMargins().bottom() / 2; 535 | //// top *= -1; 536 | //// bottom *= 1.5; 537 | // } 538 | qDebug() << __func__ << " - 'top': " << top; 539 | qDebug() << __func__ << " - 'bottom': " << bottom; 540 | setViewportMargins(0,top,0, bottom); 541 | 542 | 543 | // QScrollBar* scrollbar = verticalScrollBar(); 544 | // 545 | // qDebug() << __func__ << " - 'scrollbar->value();': " << 546 | scrollbar->value();; 547 | // qDebug() << __func__ << " - 'scrollbar->maximum();': " 548 | // << scrollbar->maximum();; 549 | 550 | 551 | // scrollbar->setValue(scrollbar->value() - offset.y()); 552 | // 553 | // setViewportMargins 554 | 555 | // setViewportMargins(0, 0, 0, bottom); 556 | */ 557 | } 558 | 559 | /* 560 | * Handle the undo event ourselves 561 | * Retains the selected text as selected after undo if 562 | * bracket closing was used otherwise performs normal undo 563 | */ 564 | void QMarkdownTextEdit::undo() { 565 | if (isReadOnly()) { 566 | return; 567 | } 568 | 569 | QTextCursor cursor = textCursor(); 570 | // if no text selected, call undo 571 | if (!cursor.hasSelection()) { 572 | QPlainTextEdit::undo(); 573 | return; 574 | } 575 | 576 | // if text is selected and bracket closing was used 577 | // we retain our selection 578 | if (_handleBracketClosingUsed) { 579 | // get the selection 580 | int selectionEnd = cursor.selectionEnd(); 581 | int selectionStart = cursor.selectionStart(); 582 | // call undo 583 | QPlainTextEdit::undo(); 584 | // select again 585 | cursor.setPosition(selectionStart - 1); 586 | cursor.setPosition(selectionEnd - 1, QTextCursor::KeepAnchor); 587 | this->setTextCursor(cursor); 588 | _handleBracketClosingUsed = false; 589 | } else { 590 | // if text was selected but bracket closing wasn't used 591 | // do normal undo 592 | QPlainTextEdit::undo(); 593 | return; 594 | } 595 | } 596 | 597 | void QMarkdownTextEdit::moveTextUpDown(bool up) { 598 | if (isReadOnly()) { 599 | return; 600 | } 601 | 602 | QTextCursor cursor = textCursor(); 603 | QTextCursor move = cursor; 604 | 605 | move.setVisualNavigation(false); 606 | 607 | move.beginEditBlock(); // open an edit block to keep undo operations sane 608 | bool hasSelection = cursor.hasSelection(); 609 | 610 | if (hasSelection) { 611 | // if there's a selection inside the block, we select the whole block 612 | move.setPosition(cursor.selectionStart()); 613 | move.movePosition(QTextCursor::StartOfBlock); 614 | move.setPosition(cursor.selectionEnd(), QTextCursor::KeepAnchor); 615 | move.movePosition( 616 | move.atBlockStart() ? QTextCursor::Left : QTextCursor::EndOfBlock, 617 | QTextCursor::KeepAnchor); 618 | } else { 619 | move.movePosition(QTextCursor::StartOfBlock); 620 | move.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); 621 | } 622 | 623 | // get the text of the current block 624 | QString text = move.selectedText(); 625 | 626 | move.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor); 627 | move.removeSelectedText(); 628 | 629 | if (up) { // up key 630 | move.movePosition(QTextCursor::PreviousBlock); 631 | move.insertBlock(); 632 | move.movePosition(QTextCursor::Left); 633 | } else { // down key 634 | move.movePosition(QTextCursor::EndOfBlock); 635 | if (move.atBlockStart()) { // empty block 636 | move.movePosition(QTextCursor::NextBlock); 637 | move.insertBlock(); 638 | move.movePosition(QTextCursor::Left); 639 | } else { 640 | move.insertBlock(); 641 | } 642 | } 643 | 644 | int start = move.position(); 645 | move.clearSelection(); 646 | move.insertText(text); 647 | int end = move.position(); 648 | 649 | // reselect 650 | if (hasSelection) { 651 | move.setPosition(end); 652 | move.setPosition(start, QTextCursor::KeepAnchor); 653 | } else { 654 | move.setPosition(start); 655 | } 656 | 657 | move.endEditBlock(); 658 | 659 | setTextCursor(move); 660 | } 661 | 662 | void QMarkdownTextEdit::setLineNumberEnabled(bool enabled) { 663 | _lineNumArea->setLineNumAreaEnabled(enabled); 664 | updateLineNumberAreaWidth(0); 665 | } 666 | 667 | /** 668 | * Resets the cursor to Qt::IBeamCursor 669 | */ 670 | void QMarkdownTextEdit::resetMouseCursor() const { 671 | QWidget *viewPort = viewport(); 672 | viewPort->setCursor(Qt::IBeamCursor); 673 | } 674 | 675 | /** 676 | * Resets the cursor to Qt::IBeamCursor if the widget looses the focus 677 | */ 678 | void QMarkdownTextEdit::focusOutEvent(QFocusEvent *event) { 679 | resetMouseCursor(); 680 | QPlainTextEdit::focusOutEvent(event); 681 | } 682 | 683 | /** 684 | * Enters a closing character after an opening character if needed 685 | * 686 | * @param openingCharacter 687 | * @param closingCharacter 688 | * @return 689 | */ 690 | bool QMarkdownTextEdit::handleBracketClosing(const QChar openingCharacter, 691 | QChar closingCharacter) { 692 | // check if bracket closing or read-only are enabled 693 | if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) { 694 | return false; 695 | } 696 | 697 | QTextCursor cursor = textCursor(); 698 | 699 | if (closingCharacter.isNull()) { 700 | closingCharacter = openingCharacter; 701 | } 702 | 703 | const QString selectedText = cursor.selectedText(); 704 | 705 | // When user currently has text selected, we prepend the openingCharacter 706 | // and append the closingCharacter. E.g. 'text' -> '(text)'. We keep the 707 | // current selectedText selected. 708 | if (!selectedText.isEmpty()) { 709 | // Insert. The selectedText is overwritten. 710 | const QString newText = 711 | openingCharacter + selectedText + closingCharacter; 712 | cursor.insertText(newText); 713 | 714 | // Re-select the selectedText. 715 | const int selectionEnd = cursor.position() - 1; 716 | const int selectionStart = selectionEnd - selectedText.length(); 717 | 718 | cursor.setPosition(selectionStart); 719 | cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor); 720 | this->setTextCursor(cursor); 721 | _handleBracketClosingUsed = true; 722 | return true; 723 | } 724 | 725 | // get the current text from the block (inserted character not included) 726 | // Remove whitespace at start of string (e.g. in multilevel-lists). 727 | static QRegularExpression regex1("^\\s+"); 728 | const QString text = cursor.block().text().remove(regex1); 729 | 730 | const int pib = cursor.positionInBlock(); 731 | bool isPreviousAsterisk = 732 | pib > 0 && pib < text.length() && text.at(pib - 1) == '*'; 733 | bool isNextAsterisk = pib < text.length() && text.at(pib) == '*'; 734 | bool isMaybeBold = isPreviousAsterisk && isNextAsterisk; 735 | if (pib < text.length() && !isMaybeBold && !text.at(pib).isSpace()) { 736 | return false; 737 | } 738 | 739 | // Default positions to move the cursor back. 740 | int cursorSubtract = 1; 741 | // Special handling for `*` opening character, as this could be: 742 | // - start of a list (or sublist); 743 | // - start of a bold text; 744 | if (openingCharacter == QLatin1Char('*')) { 745 | // don't auto complete in code block 746 | bool isInCode = 747 | MarkdownHighlighter::isCodeBlock(cursor.block().userState()); 748 | // we only do auto completion if there is a space before the cursor pos 749 | bool hasSpaceOrAsteriskBefore = !text.isEmpty() && pib > 0 && 750 | (text.at(pib - 1).isSpace() || 751 | text.at(pib - 1) == QLatin1Char('*')); 752 | // This could be the start of a list, don't autocomplete. 753 | bool isEmpty = text.isEmpty(); 754 | 755 | if (isInCode || !hasSpaceOrAsteriskBefore || isEmpty) { 756 | return false; 757 | } 758 | 759 | // bold 760 | if (isPreviousAsterisk && isNextAsterisk) { 761 | cursorSubtract = 1; 762 | } 763 | 764 | // User wants: '**'. 765 | // Not the start of a list, probably bold text. We autocomplete with 766 | // extra closingCharacter and cursorSubtract to 'catchup'. 767 | if (text == QLatin1String("*")) { 768 | cursor.insertText(QStringLiteral("*")); 769 | cursorSubtract = 2; 770 | } 771 | } 772 | 773 | // Auto-completion for ``` pair 774 | if (openingCharacter == QLatin1Char('`')) { 775 | #if QT_VERSION < QT_VERSION_CHECK(5, 12, 0) 776 | if (QRegExp(QStringLiteral("[^`]*``")).exactMatch(text)) { 777 | #else 778 | if (QRegularExpression( 779 | QRegularExpression::anchoredPattern(QStringLiteral("[^`]*``"))) 780 | .match(text) 781 | .hasMatch()) { 782 | #endif 783 | cursor.insertText(QStringLiteral("``")); 784 | cursorSubtract = 3; 785 | } 786 | } 787 | 788 | // don't auto complete in code block 789 | if (openingCharacter == QLatin1Char('<') && 790 | MarkdownHighlighter::isCodeBlock(cursor.block().userState())) { 791 | return false; 792 | } 793 | 794 | cursor.beginEditBlock(); 795 | cursor.insertText(openingCharacter); 796 | cursor.insertText(closingCharacter); 797 | cursor.setPosition(cursor.position() - cursorSubtract); 798 | cursor.endEditBlock(); 799 | 800 | setTextCursor(cursor); 801 | return true; 802 | } 803 | 804 | /** 805 | * Checks if the closing character should be output or not 806 | * 807 | * @param openingCharacter 808 | * @param closingCharacter 809 | * @return 810 | */ 811 | bool QMarkdownTextEdit::bracketClosingCheck(const QChar openingCharacter, 812 | QChar closingCharacter) { 813 | // check if bracket closing or read-only are enabled 814 | if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) { 815 | return false; 816 | } 817 | 818 | if (closingCharacter.isNull()) { 819 | closingCharacter = openingCharacter; 820 | } 821 | 822 | QTextCursor cursor = textCursor(); 823 | const int positionInBlock = cursor.positionInBlock(); 824 | 825 | // get the current text from the block 826 | const QString text = cursor.block().text(); 827 | const int textLength = text.length(); 828 | 829 | // if we are at the end of the line we just want to enter the character 830 | if (positionInBlock >= textLength) { 831 | return false; 832 | } 833 | 834 | const QChar currentChar = text.at(positionInBlock); 835 | 836 | // if (closingCharacter == openingCharacter) { 837 | 838 | // } 839 | 840 | qDebug() << __func__ << " - 'currentChar': " << currentChar; 841 | 842 | // if the current character is not the closing character we just want to 843 | // enter the character 844 | if (currentChar != closingCharacter) { 845 | return false; 846 | } 847 | 848 | const QString leftText = text.left(positionInBlock); 849 | const int openingCharacterCount = leftText.count(openingCharacter); 850 | const int closingCharacterCount = leftText.count(closingCharacter); 851 | 852 | // if there were enough opening characters just enter the character 853 | if (openingCharacterCount < (closingCharacterCount + 1)) { 854 | return false; 855 | } 856 | 857 | // move the cursor to the right and don't enter the character 858 | cursor.movePosition(QTextCursor::Right); 859 | setTextCursor(cursor); 860 | return true; 861 | } 862 | 863 | /** 864 | * Checks if the closing character should be output or not or if a closing 865 | * character after an opening character if needed 866 | * 867 | * @param quotationCharacter 868 | * @return 869 | */ 870 | bool QMarkdownTextEdit::quotationMarkCheck(const QChar quotationCharacter) { 871 | // check if bracket closing or read-only are enabled 872 | if (!(_autoTextOptions & AutoTextOption::BracketClosing) || isReadOnly()) { 873 | return false; 874 | } 875 | 876 | QTextCursor cursor = textCursor(); 877 | const int positionInBlock = cursor.positionInBlock(); 878 | 879 | // get the current text from the block 880 | const QString text = cursor.block().text(); 881 | const int textLength = text.length(); 882 | 883 | // if last char is not space, we are at word end, no autocompletion 884 | const bool isBacktick = quotationCharacter == '`'; 885 | if (!isBacktick && positionInBlock != 0 && 886 | !text.at(positionInBlock - 1).isSpace()) { 887 | return false; 888 | } 889 | 890 | // if we are at the end of the line we just want to enter the character 891 | if (positionInBlock >= textLength) { 892 | return handleBracketClosing(quotationCharacter); 893 | } 894 | 895 | const QChar currentChar = text.at(positionInBlock); 896 | 897 | // if the current character is not the quotation character we just want to 898 | // enter the character 899 | if (currentChar != quotationCharacter) { 900 | return handleBracketClosing(quotationCharacter); 901 | } 902 | 903 | // move the cursor to the right and don't enter the character 904 | cursor.movePosition(QTextCursor::Right); 905 | setTextCursor(cursor); 906 | return true; 907 | } 908 | 909 | /*********************************** 910 | * helper methods for char removal 911 | * Rules for (') and ("): 912 | * if [sp]" -> opener (sp = space) 913 | * if "[sp] -> closer 914 | ***********************************/ 915 | bool isQuotOpener(int position, const QString &text) { 916 | if (position == 0) return true; 917 | const int prevCharPos = position - 1; 918 | return text.at(prevCharPos).isSpace(); 919 | } 920 | bool isQuotCloser(int position, const QString &text) { 921 | const int nextCharPos = position + 1; 922 | if (nextCharPos >= text.length()) return true; 923 | return text.at(nextCharPos).isSpace(); 924 | } 925 | 926 | /** 927 | * Handles removing of matching brackets and other Markdown characters 928 | * Only works with backspace to remove text 929 | * 930 | * @return 931 | */ 932 | bool QMarkdownTextEdit::handleBackspaceEntered() { 933 | if (!(_autoTextOptions & AutoTextOption::BracketRemoval) || isReadOnly()) { 934 | return false; 935 | } 936 | 937 | QTextCursor cursor = textCursor(); 938 | 939 | // return if some text was selected 940 | if (!cursor.selectedText().isEmpty()) { 941 | return false; 942 | } 943 | 944 | int position = cursor.position(); 945 | const int positionInBlock = cursor.positionInBlock(); 946 | int block = cursor.block().blockNumber(); 947 | 948 | if (_highlighter) 949 | if (_highlighter->isPosInACodeSpan(block, positionInBlock - 1)) 950 | return false; 951 | 952 | // return if backspace was pressed at the beginning of a block 953 | if (positionInBlock == 0) { 954 | return false; 955 | } 956 | 957 | // get the current text from the block 958 | const QString text = cursor.block().text(); 959 | 960 | char charToRemove{}; 961 | 962 | // current char 963 | const char charInFront = text.at(positionInBlock - 1).toLatin1(); 964 | 965 | if (charInFront == '*') 966 | return handleCharRemoval(MarkdownHighlighter::RangeType::Emphasis, 967 | block, positionInBlock - 1); 968 | else if (charInFront == '`') 969 | return handleCharRemoval(MarkdownHighlighter::RangeType::CodeSpan, 970 | block, positionInBlock - 1); 971 | 972 | // handle removal of ", ', and brackets 973 | 974 | // is it opener? 975 | int pos = _openingCharacters.indexOf(charInFront); 976 | // for " and ' 977 | bool isOpener = false; 978 | bool isCloser = false; 979 | if (pos == 5 || pos == 6) { 980 | isOpener = isQuotOpener(positionInBlock - 1, text); 981 | } else { 982 | isOpener = pos != -1; 983 | } 984 | if (isOpener) { 985 | charToRemove = _closingCharacters.at(pos); 986 | } else { 987 | // is it closer? 988 | pos = _closingCharacters.indexOf(charInFront); 989 | if (pos == 5 || pos == 6) 990 | isCloser = isQuotCloser(positionInBlock - 1, text); 991 | else 992 | isCloser = pos != -1; 993 | if (isCloser) 994 | charToRemove = _openingCharacters.at(pos); 995 | else 996 | return false; 997 | } 998 | 999 | int charToRemoveIndex = -1; 1000 | if (isOpener) { 1001 | bool closer = true; 1002 | charToRemoveIndex = text.indexOf(charToRemove, positionInBlock); 1003 | if (charToRemoveIndex == -1) return false; 1004 | if (pos == 5 || pos == 6) 1005 | closer = isQuotCloser(charToRemoveIndex, text); 1006 | if (!closer) return false; 1007 | cursor.setPosition(position + (charToRemoveIndex - positionInBlock)); 1008 | cursor.deleteChar(); 1009 | } else if (isCloser) { 1010 | charToRemoveIndex = text.lastIndexOf(charToRemove, positionInBlock - 2); 1011 | if (charToRemoveIndex == -1) return false; 1012 | bool opener = true; 1013 | if (pos == 5 || pos == 6) 1014 | opener = isQuotOpener(charToRemoveIndex, text); 1015 | if (!opener) return false; 1016 | const int pos = position - (positionInBlock - charToRemoveIndex); 1017 | cursor.setPosition(pos); 1018 | cursor.deleteChar(); 1019 | position -= 1; 1020 | } else { 1021 | charToRemoveIndex = text.lastIndexOf(charToRemove, positionInBlock - 2); 1022 | if (charToRemoveIndex == -1) return false; 1023 | const int pos = position - (positionInBlock - charToRemoveIndex); 1024 | cursor.setPosition(pos); 1025 | cursor.deleteChar(); 1026 | position -= 1; 1027 | } 1028 | 1029 | // moving the cursor back to the old position so the previous character 1030 | // can be removed 1031 | cursor.setPosition(position); 1032 | setTextCursor(cursor); 1033 | return false; 1034 | } 1035 | 1036 | bool QMarkdownTextEdit::handleCharRemoval(MarkdownHighlighter::RangeType type, 1037 | int block, int position) { 1038 | if (!_highlighter) return false; 1039 | 1040 | auto range = _highlighter->findPositionInRanges(type, block, position); 1041 | if (range == QPair{-1, -1}) return false; 1042 | 1043 | int charToRemovePos = range.first; 1044 | if (position == range.first) charToRemovePos = range.second; 1045 | 1046 | QTextCursor cursor = textCursor(); 1047 | auto gpos = cursor.position(); 1048 | 1049 | if (charToRemovePos > position) { 1050 | cursor.setPosition(gpos + (charToRemovePos - (position + 1))); 1051 | } else { 1052 | cursor.setPosition(gpos - (position - charToRemovePos + 1)); 1053 | gpos--; 1054 | } 1055 | 1056 | cursor.deleteChar(); 1057 | cursor.setPosition(gpos); 1058 | setTextCursor(cursor); 1059 | return false; 1060 | } 1061 | 1062 | void QMarkdownTextEdit::updateLineNumAreaGeometry() { 1063 | const auto contentsRect = this->contentsRect(); 1064 | const QRect newGeometry = {contentsRect.left(), contentsRect.top(), 1065 | _lineNumArea->sizeHint().width(), 1066 | contentsRect.height()}; 1067 | auto oldGeometry = _lineNumArea->geometry(); 1068 | if (newGeometry != oldGeometry) { 1069 | _lineNumArea->setGeometry(newGeometry); 1070 | } 1071 | } 1072 | 1073 | void QMarkdownTextEdit::resizeEvent(QResizeEvent *event) { 1074 | QPlainTextEdit::resizeEvent(event); 1075 | updateLineNumAreaGeometry(); 1076 | } 1077 | 1078 | /** 1079 | * Increases (or decreases) the indention of the selected text 1080 | * (if there is a text selected) in the noteTextEdit 1081 | * @return 1082 | */ 1083 | bool QMarkdownTextEdit::increaseSelectedTextIndention( 1084 | bool reverse, const QString &indentCharacters) { 1085 | QTextCursor cursor = this->textCursor(); 1086 | QString selectedText = cursor.selectedText(); 1087 | 1088 | if (!selectedText.isEmpty()) { 1089 | // Start the selection at start of the first block of the selection 1090 | int end = cursor.selectionEnd(); 1091 | cursor.setPosition(cursor.selectionStart()); 1092 | cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::MoveAnchor); 1093 | cursor.setPosition(end, QTextCursor::KeepAnchor); 1094 | this->setTextCursor(cursor); 1095 | selectedText = cursor.selectedText(); 1096 | 1097 | // we need this strange newline character we are getting in the 1098 | // selected text for newlines 1099 | const QString newLine = 1100 | QString::fromUtf8(QByteArray::fromHex(QByteArrayLiteral("e280a9"))); 1101 | QString newText; 1102 | 1103 | if (reverse) { 1104 | // un-indent text 1105 | 1106 | const int indentSize = indentCharacters == QStringLiteral("\t") 1107 | ? 4 1108 | : indentCharacters.length(); 1109 | 1110 | // remove leading \t or spaces in following lines 1111 | newText = selectedText.replace( 1112 | QRegularExpression(newLine + QStringLiteral("(\\t| {1,") + 1113 | QString::number(indentSize) + 1114 | QStringLiteral("})")), 1115 | QStringLiteral("\n")); 1116 | 1117 | // remove leading \t or spaces in first line 1118 | newText.remove(QRegularExpression(QStringLiteral("^(\\t| {1,") + 1119 | QString::number(indentSize) + 1120 | QStringLiteral("})"))); 1121 | } else { 1122 | // replace trailing new line to prevent an indent of the line after 1123 | // the selection 1124 | newText = selectedText.replace( 1125 | QRegularExpression(QRegularExpression::escape(newLine) + 1126 | QStringLiteral("$")), 1127 | QStringLiteral("\n")); 1128 | 1129 | // indent text 1130 | newText.replace(newLine, QStringLiteral("\n") + indentCharacters) 1131 | .prepend(indentCharacters); 1132 | 1133 | // remove trailing \t 1134 | static QRegularExpression regex1(QStringLiteral("\\t$")); 1135 | newText.remove(regex1); 1136 | } 1137 | 1138 | // insert the new text 1139 | cursor.insertText(newText); 1140 | 1141 | // update the selection to the new text 1142 | cursor.setPosition(cursor.position() - newText.size(), 1143 | QTextCursor::KeepAnchor); 1144 | this->setTextCursor(cursor); 1145 | 1146 | return true; 1147 | } else if (reverse) { 1148 | const int indentSize = indentCharacters.length(); 1149 | 1150 | // do the check as often as we have characters to un-indent 1151 | for (int i = 1; i <= indentSize; i++) { 1152 | // if nothing was selected but we want to reverse the indention 1153 | // check if there is a \t in front or after the cursor and remove it 1154 | // if so 1155 | const int position = cursor.position(); 1156 | 1157 | if (!cursor.atStart()) { 1158 | // get character in front of cursor 1159 | cursor.setPosition(position - 1, QTextCursor::KeepAnchor); 1160 | } 1161 | 1162 | // check for \t or space in front of cursor 1163 | static QRegularExpression regex1(QStringLiteral("[\\t ]")); 1164 | QRegularExpressionMatch match = regex1.match(cursor.selectedText()); 1165 | 1166 | if (!match.hasMatch()) { 1167 | // (select to) check for \t or space after the cursor 1168 | cursor.setPosition(position); 1169 | 1170 | if (!cursor.atEnd()) { 1171 | cursor.setPosition(position + 1, QTextCursor::KeepAnchor); 1172 | } 1173 | } 1174 | 1175 | match = regex1.match(cursor.selectedText()); 1176 | 1177 | if (match.hasMatch()) { 1178 | cursor.removeSelectedText(); 1179 | } 1180 | 1181 | cursor = this->textCursor(); 1182 | } 1183 | 1184 | return true; 1185 | } 1186 | 1187 | // else just insert indentCharacters 1188 | cursor.insertText(indentCharacters); 1189 | 1190 | return true; 1191 | } 1192 | 1193 | /** 1194 | * @brief Opens the link (if any) at the current cursor position 1195 | */ 1196 | bool QMarkdownTextEdit::openLinkAtCursorPosition() { 1197 | QTextCursor cursor = this->textCursor(); 1198 | const int clickedPosition = cursor.position(); 1199 | 1200 | // select the text in the clicked block and find out on 1201 | // which position we clicked 1202 | cursor.movePosition(QTextCursor::StartOfBlock); 1203 | const int positionFromStart = clickedPosition - cursor.position(); 1204 | cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); 1205 | 1206 | const QString selectedText = cursor.selectedText(); 1207 | 1208 | // find out which url in the selected text was clicked 1209 | const QString urlString = 1210 | getMarkdownUrlAtPosition(selectedText, positionFromStart); 1211 | const QUrl url = QUrl(urlString); 1212 | const bool isRelativeFileUrl = 1213 | urlString.startsWith(QLatin1String("file://..")); 1214 | const bool isLegacyAttachmentUrl = 1215 | urlString.startsWith(QLatin1String("file://attachments")); 1216 | 1217 | qDebug() << __func__ << " - 'emit urlClicked( urlString )': " << urlString; 1218 | 1219 | Q_EMIT urlClicked(urlString); 1220 | 1221 | if ((url.isValid() && isValidUrl(urlString)) || isRelativeFileUrl || 1222 | isLegacyAttachmentUrl) { 1223 | // ignore some schemata 1224 | if (!(_ignoredClickUrlSchemata.contains(url.scheme()) || 1225 | isRelativeFileUrl || isLegacyAttachmentUrl)) { 1226 | // open the url 1227 | openUrl(urlString); 1228 | } 1229 | 1230 | return true; 1231 | } 1232 | 1233 | return false; 1234 | } 1235 | 1236 | /** 1237 | * Checks if urlString is a valid url 1238 | * 1239 | * @param urlString 1240 | * @return 1241 | */ 1242 | bool QMarkdownTextEdit::isValidUrl(const QString &urlString) { 1243 | static QRegularExpression regex(R"(^\w+:\/\/.+)"); 1244 | const QRegularExpressionMatch match = regex.match(urlString); 1245 | return match.hasMatch(); 1246 | } 1247 | 1248 | /** 1249 | * Handles clicked urls 1250 | * 1251 | * examples: 1252 | * - opens the webpage 1253 | * - opens the file 1254 | * "/path/to/my/file/QOwnNotes.pdf" if the operating system supports that 1255 | * handler 1256 | */ 1257 | void QMarkdownTextEdit::openUrl(const QString &urlString) { 1258 | qDebug() << "QMarkdownTextEdit " << __func__ 1259 | << " - 'urlString': " << urlString; 1260 | 1261 | QDesktopServices::openUrl(QUrl(urlString)); 1262 | } 1263 | 1264 | /** 1265 | * @brief Returns the highlighter instance 1266 | * @return 1267 | */ 1268 | MarkdownHighlighter *QMarkdownTextEdit::highlighter() { return _highlighter; } 1269 | 1270 | /** 1271 | * @brief Returns the searchWidget instance 1272 | * @return 1273 | */ 1274 | QPlainTextEditSearchWidget *QMarkdownTextEdit::searchWidget() { 1275 | return _searchWidget; 1276 | } 1277 | 1278 | /** 1279 | * @brief Sets url schemata that will be ignored when clicked on 1280 | * @param urlSchemes 1281 | */ 1282 | void QMarkdownTextEdit::setIgnoredClickUrlSchemata( 1283 | QStringList ignoredUrlSchemata) { 1284 | _ignoredClickUrlSchemata = std::move(ignoredUrlSchemata); 1285 | } 1286 | 1287 | /** 1288 | * @brief Returns a map of parsed Markdown urls with their link texts as key 1289 | * 1290 | * @param text 1291 | * @return parsed urls 1292 | */ 1293 | QMap QMarkdownTextEdit::parseMarkdownUrlsFromText( 1294 | const QString &text) { 1295 | QMap urlMap; 1296 | QRegularExpressionMatchIterator iterator; 1297 | 1298 | // match urls like this: 1299 | // re = QRegularExpression("(<(.+?:\\/\\/.+?)>)"); 1300 | static QRegularExpression regex1(QStringLiteral("(<(.+?)>)")); 1301 | iterator = regex1.globalMatch(text); 1302 | while (iterator.hasNext()) { 1303 | QRegularExpressionMatch match = iterator.next(); 1304 | QString linkText = match.captured(1); 1305 | QString url = match.captured(2); 1306 | urlMap[linkText] = url; 1307 | } 1308 | 1309 | // match urls like this: [this url](http://mylink) 1310 | // QRegularExpression re("(\\[.*?\\]\\((.+?:\\/\\/.+?)\\))"); 1311 | static QRegularExpression regex2(R"((\[.*?\]\((.+?)\)))"); 1312 | iterator = regex2.globalMatch(text); 1313 | while (iterator.hasNext()) { 1314 | QRegularExpressionMatch match = iterator.next(); 1315 | QString linkText = match.captured(1); 1316 | QString url = match.captured(2); 1317 | urlMap[linkText] = url; 1318 | } 1319 | 1320 | // match urls like this: http://mylink 1321 | static QRegularExpression regex3(R"(\b\w+?:\/\/[^\s]+[^\s>\)])"); 1322 | iterator = regex3.globalMatch(text); 1323 | while (iterator.hasNext()) { 1324 | QRegularExpressionMatch match = iterator.next(); 1325 | QString url = match.captured(0); 1326 | urlMap[url] = url; 1327 | } 1328 | 1329 | // match urls like this: www.github.com 1330 | static QRegularExpression regex4(R"(\bwww\.[^\s]+\.[^\s]+\b)"); 1331 | iterator = regex4.globalMatch(text); 1332 | while (iterator.hasNext()) { 1333 | QRegularExpressionMatch match = iterator.next(); 1334 | QString url = match.captured(0); 1335 | urlMap[url] = QStringLiteral("http://") + url; 1336 | } 1337 | 1338 | // match reference urls like this: [this url][1] with this later: 1339 | // [1]: http://domain 1340 | static QRegularExpression regex5(R"((\[.*?\]\[(.+?)\]))"); 1341 | iterator = regex5.globalMatch(text); 1342 | while (iterator.hasNext()) { 1343 | QRegularExpressionMatch match = iterator.next(); 1344 | QString linkText = match.captured(1); 1345 | QString referenceId = match.captured(2); 1346 | 1347 | // search for the referenced url in the whole text edit 1348 | // QRegularExpression refRegExp( 1349 | // "\\[" + QRegularExpression::escape(referenceId) + 1350 | // "\\]: (.+?:\\/\\/.+)"); 1351 | QRegularExpression refRegExp(QStringLiteral("\\[") + 1352 | QRegularExpression::escape(referenceId) + 1353 | QStringLiteral("\\]: (.+)")); 1354 | QRegularExpressionMatch urlMatch = refRegExp.match(toPlainText()); 1355 | 1356 | if (urlMatch.hasMatch()) { 1357 | QString url = urlMatch.captured(1); 1358 | urlMap[linkText] = url; 1359 | } 1360 | } 1361 | 1362 | return urlMap; 1363 | } 1364 | 1365 | /** 1366 | * @brief Returns the Markdown url at position 1367 | * @param text 1368 | * @param position 1369 | * @return url string 1370 | */ 1371 | QString QMarkdownTextEdit::getMarkdownUrlAtPosition(const QString &text, 1372 | int position) { 1373 | QString url; 1374 | 1375 | // get a map of parsed Markdown urls with their link texts as key 1376 | const QMap urlMap = parseMarkdownUrlsFromText(text); 1377 | QMap::const_iterator i = urlMap.constBegin(); 1378 | for (; i != urlMap.constEnd(); ++i) { 1379 | const QString &linkText = i.key(); 1380 | const QString &urlString = i.value(); 1381 | 1382 | const int foundPositionStart = text.indexOf(linkText); 1383 | 1384 | if (foundPositionStart >= 0) { 1385 | // calculate end position of found linkText 1386 | const int foundPositionEnd = foundPositionStart + linkText.size(); 1387 | 1388 | // check if position is in found string range 1389 | if ((position >= foundPositionStart) && 1390 | (position <= foundPositionEnd)) { 1391 | url = urlString; 1392 | break; 1393 | } 1394 | } 1395 | } 1396 | 1397 | return url; 1398 | } 1399 | 1400 | /** 1401 | * @brief Duplicates the text in the text edit 1402 | */ 1403 | void QMarkdownTextEdit::duplicateText() { 1404 | if (isReadOnly()) { 1405 | return; 1406 | } 1407 | 1408 | QTextCursor cursor = this->textCursor(); 1409 | QString selectedText = cursor.selectedText(); 1410 | 1411 | // duplicate line if no text was selected 1412 | if (selectedText.isEmpty()) { 1413 | const int position = cursor.position(); 1414 | 1415 | // select the whole line 1416 | cursor.movePosition(QTextCursor::StartOfBlock); 1417 | cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); 1418 | 1419 | const int positionDiff = cursor.position() - position; 1420 | selectedText = "\n" + cursor.selectedText(); 1421 | 1422 | // insert text with new line at end of the selected line 1423 | cursor.setPosition(cursor.selectionEnd()); 1424 | cursor.insertText(selectedText); 1425 | 1426 | // set the position to same position it was in the duplicated line 1427 | cursor.setPosition(cursor.position() - positionDiff); 1428 | } else { 1429 | // duplicate selected text 1430 | cursor.setPosition(cursor.selectionEnd()); 1431 | const int selectionStart = cursor.position(); 1432 | 1433 | // insert selected text 1434 | cursor.insertText(selectedText); 1435 | const int selectionEnd = cursor.position(); 1436 | 1437 | // select the inserted text 1438 | cursor.setPosition(selectionStart); 1439 | cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor); 1440 | } 1441 | 1442 | this->setTextCursor(cursor); 1443 | } 1444 | 1445 | void QMarkdownTextEdit::setText(const QString &text) { setPlainText(text); } 1446 | 1447 | void QMarkdownTextEdit::setPlainText(const QString &text) { 1448 | // clear the dirty blocks vector to increase performance and prevent 1449 | // a possible crash in QSyntaxHighlighter::rehighlightBlock 1450 | if (_highlighter) _highlighter->clearDirtyBlocks(); 1451 | 1452 | QPlainTextEdit::setPlainText(text); 1453 | adjustRightMargin(); 1454 | } 1455 | 1456 | /** 1457 | * Uses another widget as parent for the search widget 1458 | */ 1459 | void QMarkdownTextEdit::initSearchFrame(QWidget *searchFrame, bool darkMode) { 1460 | _searchFrame = searchFrame; 1461 | 1462 | // remove the search widget from our layout 1463 | layout()->removeWidget(_searchWidget); 1464 | 1465 | QLayout *layout = _searchFrame->layout(); 1466 | 1467 | // create a grid layout for the frame and add the search widget to it 1468 | if (layout == nullptr) { 1469 | layout = new QVBoxLayout(_searchFrame); 1470 | layout->setSpacing(0); 1471 | layout->setContentsMargins(0, 0, 0, 0); 1472 | } 1473 | 1474 | _searchWidget->setDarkMode(darkMode); 1475 | layout->addWidget(_searchWidget); 1476 | _searchFrame->setLayout(layout); 1477 | } 1478 | 1479 | /** 1480 | * Hides the text edit and the search widget 1481 | */ 1482 | void QMarkdownTextEdit::hide() { 1483 | _searchWidget->hide(); 1484 | QWidget::hide(); 1485 | } 1486 | 1487 | /** 1488 | * Handles an entered return key 1489 | */ 1490 | bool QMarkdownTextEdit::handleReturnEntered() { 1491 | if (isReadOnly()) { 1492 | return true; 1493 | } 1494 | 1495 | // This will be the main cursor to add or remove text 1496 | QTextCursor cursor = this->textCursor(); 1497 | 1498 | // We need a 2nd cursor to get the text of the current block without moving 1499 | // the main cursor that is used to remove the selected text 1500 | QTextCursor cursor2 = this->textCursor(); 1501 | cursor2.select(QTextCursor::BlockUnderCursor); 1502 | const QString currentLineText = cursor2.selectedText().trimmed(); 1503 | 1504 | const int position = cursor.position(); 1505 | const bool cursorAtBlockStart = cursor.atBlockStart(); 1506 | cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); 1507 | const QString currentLinePartialText = cursor.selectedText(); 1508 | 1509 | // if return is pressed and there is just an unordered list symbol then we 1510 | // want to remove the list symbol Valid listCharacters: '+ ', '-' , '* ', '+ 1511 | // [ ] ', '+ [x] ', '- [ ] ', '- [-] ', '- [x] ', '* [ ] ', '* [x] '. 1512 | static QRegularExpression regex1( 1513 | R"(^(\s*)([+|\-|\*] \[(x|-| |)\]|[+\-\*])(\s+)$)"); 1514 | QRegularExpressionMatchIterator iterator = 1515 | regex1.globalMatch(currentLinePartialText); 1516 | if (iterator.hasNext()) { 1517 | cursor.removeSelectedText(); 1518 | return true; 1519 | } 1520 | 1521 | // if return is pressed and there is just an ordered list symbol then we 1522 | // want to remove the list symbol 1523 | static QRegularExpression regex2(R"(^(\s*)(\d+[\.|\)])(\s+)$)"); 1524 | iterator = regex2.globalMatch(currentLinePartialText); 1525 | if (iterator.hasNext()) { 1526 | qDebug() << cursor.selectedText(); 1527 | cursor.removeSelectedText(); 1528 | return true; 1529 | } 1530 | 1531 | // Check if we are in an unordered list. 1532 | // We are in a list when we have '* ', '- ' or '+ ', possibly with preceding 1533 | // whitespace. If e.g. user has entered '**text**' and pressed enter - we 1534 | // don't want to do more list-stuff. 1535 | QString currentLine = currentLinePartialText.trimmed(); 1536 | QChar char0; 1537 | QChar char1; 1538 | if (currentLine.length() >= 1) char0 = currentLine.at(0); 1539 | if (currentLine.length() >= 2) char1 = currentLine.at(1); 1540 | const bool inList = 1541 | ((char0 == QLatin1Char('*') || char0 == QLatin1Char('-') || 1542 | char0 == QLatin1Char('+')) && 1543 | char1 == QLatin1Char(' ')); 1544 | 1545 | if (inList) { 1546 | // if the current line starts with a list character (possibly after 1547 | // whitespaces) add the whitespaces at the next line too 1548 | // Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ', 1549 | // '- [x] ', '- [-] ', '* [ ] ', '* [x] '. 1550 | static QRegularExpression regex3( 1551 | R"(^(\s*)([+|\-|\*] \[(x|-| |)\]|[+\-\*])(\s+))"); 1552 | iterator = regex3.globalMatch(currentLinePartialText); 1553 | if (iterator.hasNext()) { 1554 | const QRegularExpressionMatch match = iterator.next(); 1555 | const QString whitespaces = match.captured(1); 1556 | QString listCharacter = match.captured(2); 1557 | const QString whitespaceCharacter = match.captured(4); 1558 | 1559 | static QRegularExpression regex4(R"(^([+|\-|\*]) \[(x| |\-|)\])"); 1560 | // start new checkbox list item with an unchecked checkbox 1561 | iterator = regex4.globalMatch(listCharacter); 1562 | if (iterator.hasNext()) { 1563 | const QRegularExpressionMatch match1 = iterator.next(); 1564 | const QString realListCharacter = match1.captured(1); 1565 | listCharacter = realListCharacter + QStringLiteral(" [ ]"); 1566 | } 1567 | 1568 | cursor.setPosition(position); 1569 | cursor.insertText("\n" + whitespaces + listCharacter + 1570 | whitespaceCharacter); 1571 | 1572 | // scroll to the cursor if we are at the bottom of the document 1573 | ensureCursorVisible(); 1574 | return true; 1575 | } 1576 | } 1577 | 1578 | // check for ordered lists and increment the list number in the next line 1579 | static QRegularExpression regex5(R"(^(\s*)(\d+)([\.|\)])(\s+))"); 1580 | iterator = regex5.globalMatch(currentLinePartialText); 1581 | if (iterator.hasNext()) { 1582 | const QRegularExpressionMatch match = iterator.next(); 1583 | const QString whitespaces = match.captured(1); 1584 | const uint listNumber = match.captured(2).toUInt(); 1585 | const QString listMarker = match.captured(3); 1586 | const QString whitespaceCharacter = match.captured(4); 1587 | 1588 | cursor.setPosition(position); 1589 | cursor.insertText("\n" + whitespaces + QString::number(listNumber + 1) + 1590 | listMarker + whitespaceCharacter); 1591 | 1592 | // scroll to the cursor if we are at the bottom of the document 1593 | ensureCursorVisible(); 1594 | return true; 1595 | } 1596 | 1597 | // intent next line with same whitespaces as in current line 1598 | static QRegularExpression regex6(R"(^(\s+))"); 1599 | iterator = regex6.globalMatch(currentLinePartialText); 1600 | if (iterator.hasNext()) { 1601 | const QRegularExpressionMatch match = iterator.next(); 1602 | const QString whitespaces = match.captured(1); 1603 | 1604 | cursor.setPosition(position); 1605 | cursor.insertText("\n" + whitespaces); 1606 | 1607 | // scroll to the cursor if we are at the bottom of the document 1608 | ensureCursorVisible(); 1609 | return true; 1610 | } 1611 | 1612 | // Add new list item above current line if we are at the start the line of a 1613 | // list item 1614 | if (cursorAtBlockStart) { 1615 | static QRegularExpression regex7( 1616 | R"(^([+|\-|\*] \[(x|-| |)\]|[+\-\*])(\s+))"); 1617 | iterator = regex7.globalMatch(currentLineText); 1618 | if (iterator.hasNext()) { 1619 | const QRegularExpressionMatch match = iterator.next(); 1620 | QString listCharacter = match.captured(1); 1621 | const QString whitespaceCharacter = match.captured(3); 1622 | 1623 | static QRegularExpression regex8(R"(^([+|\-|\*]) \[(x| |\-|)\])"); 1624 | // start new checkbox list item with an unchecked checkbox 1625 | iterator = regex8.globalMatch(listCharacter); 1626 | if (iterator.hasNext()) { 1627 | const QRegularExpressionMatch match1 = iterator.next(); 1628 | const QString realListCharacter = match1.captured(1); 1629 | listCharacter = realListCharacter + QStringLiteral(" [ ]"); 1630 | } 1631 | 1632 | cursor.setPosition(position); 1633 | // Enter new list item above current line 1634 | cursor.insertText(listCharacter + whitespaceCharacter + "\n"); 1635 | // Move the cursor at the end of the new list item 1636 | cursor.movePosition(QTextCursor::Left); 1637 | setTextCursor(cursor); 1638 | 1639 | // scroll to the cursor if we are at the bottom of the document 1640 | ensureCursorVisible(); 1641 | return true; 1642 | } 1643 | } 1644 | 1645 | return false; 1646 | } 1647 | 1648 | /** 1649 | * Handles entered tab or reverse tab keys 1650 | */ 1651 | bool QMarkdownTextEdit::handleTabEntered(bool reverse, 1652 | const QString &indentCharacters) { 1653 | if (isReadOnly()) { 1654 | return true; 1655 | } 1656 | 1657 | QTextCursor cursor = this->textCursor(); 1658 | 1659 | // only check for lists if we haven't a text selected 1660 | if (cursor.selectedText().isEmpty()) { 1661 | cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); 1662 | const QString currentLineText = cursor.selectedText(); 1663 | 1664 | // check if we want to indent or un-indent an ordered list 1665 | // Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ', 1666 | // '- [x] ', '- [-] ', '* [ ] ', '* [x] '. 1667 | static QRegularExpression regex1( 1668 | R"(^(\s*)([+|\-|\*] \[(x|-| )\]|[+\-\*])(\s+)$)"); 1669 | QRegularExpressionMatchIterator i = regex1.globalMatch(currentLineText); 1670 | 1671 | if (i.hasNext()) { 1672 | QRegularExpressionMatch match = i.next(); 1673 | QString whitespaces = match.captured(1); 1674 | const QString listCharacter = match.captured(2); 1675 | const QString whitespaceCharacter = match.captured(4); 1676 | 1677 | // add or remove one tabulator key 1678 | if (reverse) { 1679 | // remove one set of indentCharacters or a tabulator 1680 | whitespaces.remove(QRegularExpression( 1681 | QStringLiteral("^(\\t|") + 1682 | QRegularExpression::escape(indentCharacters) + 1683 | QStringLiteral(")"))); 1684 | 1685 | } else { 1686 | whitespaces += indentCharacters; 1687 | } 1688 | 1689 | cursor.insertText(whitespaces + listCharacter + 1690 | whitespaceCharacter); 1691 | return true; 1692 | } 1693 | 1694 | // check if we want to indent or un-indent an ordered list 1695 | static QRegularExpression regex2(R"(^(\s*)(\d+)([\.|\)])(\s+)$)"); 1696 | i = regex2.globalMatch(currentLineText); 1697 | 1698 | if (i.hasNext()) { 1699 | const QRegularExpressionMatch match = i.next(); 1700 | QString whitespaces = match.captured(1); 1701 | const QString listCharacter = match.captured(2); 1702 | const QString listMarker = match.captured(3); 1703 | const QString whitespaceCharacter = match.captured(4); 1704 | 1705 | // add or remove one tabulator key 1706 | if (reverse) { 1707 | whitespaces.chop(1); 1708 | } else { 1709 | whitespaces += indentCharacters; 1710 | } 1711 | 1712 | cursor.insertText(whitespaces + listCharacter + listMarker + 1713 | whitespaceCharacter); 1714 | return true; 1715 | } 1716 | } 1717 | 1718 | // check if we want to indent the whole text 1719 | return increaseSelectedTextIndention(reverse, indentCharacters); 1720 | } 1721 | 1722 | /** 1723 | * Sets the auto text options 1724 | */ 1725 | void QMarkdownTextEdit::setAutoTextOptions(AutoTextOptions options) { 1726 | _autoTextOptions = options; 1727 | } 1728 | 1729 | void QMarkdownTextEdit::updateLineNumberArea(const QRect rect, int dy) { 1730 | if (dy) 1731 | _lineNumArea->scroll(0, dy); 1732 | else 1733 | _lineNumArea->update(0, rect.y(), _lineNumArea->sizeHint().width(), 1734 | rect.height()); 1735 | 1736 | updateLineNumAreaGeometry(); 1737 | 1738 | if (rect.contains(viewport()->rect())) { 1739 | updateLineNumberAreaWidth(0); 1740 | } 1741 | } 1742 | 1743 | void QMarkdownTextEdit::updateLineNumberAreaWidth(int) { 1744 | QSignalBlocker blocker(this); 1745 | const auto oldMargins = viewportMargins(); 1746 | const int width = 1747 | _lineNumArea->isLineNumAreaEnabled() 1748 | ? _lineNumArea->sizeHint().width() + _lineNumberLeftMarginOffset 1749 | : oldMargins.left(); 1750 | const auto newMargins = QMargins{width, oldMargins.top(), 1751 | oldMargins.right(), oldMargins.bottom()}; 1752 | 1753 | if (newMargins != oldMargins) { 1754 | setViewportMargins(newMargins); 1755 | } 1756 | 1757 | // Grow lineNumArea font-size with the font size of the editor 1758 | const int pointSize = this->font().pointSize(); 1759 | if (pointSize > 0) { 1760 | QFont font = _lineNumArea->font(); 1761 | font.setPointSize(pointSize); 1762 | _lineNumArea->setFont(font); 1763 | } 1764 | } 1765 | 1766 | /** 1767 | * @param e 1768 | * @details This does two things 1769 | * 1. Overrides QPlainTextEdit::paintEvent to fix the RTL bug of QPlainTextEdit 1770 | * 2. Paints a rectangle around code block fences [Code taken from 1771 | * ghostwriter(which in turn is based on QPlaintextEdit::paintEvent() with 1772 | * modifications and minor improvements for our use 1773 | */ 1774 | void QMarkdownTextEdit::paintEvent(QPaintEvent *e) { 1775 | QTextBlock block = firstVisibleBlock(); 1776 | 1777 | QPainter painter(viewport()); 1778 | const QRect viewportRect = viewport()->rect(); 1779 | // painter.fillRect(viewportRect, Qt::transparent); 1780 | bool firstVisible = true; 1781 | QPointF offset(contentOffset()); 1782 | QRectF blockAreaRect; // Code or block quote rect. 1783 | bool inBlockArea = false; 1784 | 1785 | bool clipTop = false; 1786 | bool drawBlock = false; 1787 | qreal dy = 0.0; 1788 | bool done = false; 1789 | 1790 | const QColor &color = MarkdownHighlighter::codeBlockBackgroundColor(); 1791 | const int cornerRadius = 5; 1792 | 1793 | while (block.isValid() && !done) { 1794 | const QRectF r = blockBoundingRect(block).translated(offset); 1795 | const int state = block.userState(); 1796 | 1797 | if (!inBlockArea && MarkdownHighlighter::isCodeBlock(state)) { 1798 | // skip the backticks 1799 | if (!block.text().startsWith(QLatin1String("```")) && 1800 | !block.text().startsWith(QLatin1String("~~~"))) { 1801 | blockAreaRect = r; 1802 | dy = 0.0; 1803 | inBlockArea = true; 1804 | } 1805 | 1806 | // If this is the first visible block within the viewport 1807 | // and if the previous block is part of the text block area, 1808 | // then the rectangle to draw for the block area will have 1809 | // its top clipped by the viewport and will need to be 1810 | // drawn specially. 1811 | const int prevBlockState = block.previous().userState(); 1812 | if (firstVisible && 1813 | MarkdownHighlighter::isCodeBlock(prevBlockState)) { 1814 | clipTop = true; 1815 | } 1816 | } 1817 | // Else if the block ends a text block area... 1818 | else if (inBlockArea && MarkdownHighlighter::isCodeBlockEnd(state)) { 1819 | drawBlock = true; 1820 | inBlockArea = false; 1821 | blockAreaRect.setHeight(dy); 1822 | } 1823 | // If the block is at the end of the document and ends a text 1824 | // block area... 1825 | // 1826 | if (inBlockArea && block == this->document()->lastBlock()) { 1827 | drawBlock = true; 1828 | inBlockArea = false; 1829 | dy += r.height(); 1830 | blockAreaRect.setHeight(dy); 1831 | } 1832 | offset.ry() += r.height(); 1833 | dy += r.height(); 1834 | 1835 | // If this is the last text block visible within the viewport... 1836 | if (offset.y() > viewportRect.height()) { 1837 | if (inBlockArea) { 1838 | blockAreaRect.setHeight(dy); 1839 | drawBlock = true; 1840 | } 1841 | 1842 | // Finished drawing. 1843 | done = true; 1844 | } 1845 | // If this is the last text block visible within the viewport... 1846 | if (offset.y() > viewportRect.height()) { 1847 | if (inBlockArea) { 1848 | blockAreaRect.setHeight(dy); 1849 | drawBlock = true; 1850 | } 1851 | // Finished drawing. 1852 | done = true; 1853 | } 1854 | 1855 | if (drawBlock) { 1856 | painter.setCompositionMode(QPainter::CompositionMode_SourceOver); 1857 | painter.setPen(Qt::NoPen); 1858 | painter.setBrush(QBrush(color)); 1859 | 1860 | // If the first visible block is "clipped" such that the previous 1861 | // block is part of the text block area, then only draw a rectangle 1862 | // with the bottom corners rounded, and with the top corners square 1863 | // to reflect that the first visible block is part of a larger block 1864 | // of text. 1865 | // 1866 | if (clipTop) { 1867 | QPainterPath path; 1868 | path.setFillRule(Qt::WindingFill); 1869 | path.addRoundedRect(blockAreaRect, cornerRadius, cornerRadius); 1870 | qreal adjustedHeight = blockAreaRect.height() / 2; 1871 | path.addRect(blockAreaRect.adjusted(0, 0, 0, -adjustedHeight)); 1872 | painter.drawPath(path.simplified()); 1873 | clipTop = false; 1874 | } 1875 | // Else draw the entire rectangle with all corners rounded. 1876 | else { 1877 | painter.drawRoundedRect(blockAreaRect, cornerRadius, 1878 | cornerRadius); 1879 | } 1880 | 1881 | drawBlock = false; 1882 | } 1883 | 1884 | // this fixes the RTL bug of QPlainTextEdit 1885 | // https://bugreports.qt.io/browse/QTBUG-7516 1886 | if (block.text().isRightToLeft()) { 1887 | QTextLayout *layout = block.layout(); 1888 | // opt = document()->defaultTextOption(); 1889 | QTextOption opt = QTextOption(Qt::AlignRight); 1890 | opt.setTextDirection(Qt::RightToLeft); 1891 | layout->setTextOption(opt); 1892 | } 1893 | 1894 | // Current line highlight 1895 | QTextCursor cursor = textCursor(); 1896 | if (highlightCurrentLine() && cursor.block() == block) { 1897 | QTextLine line = 1898 | block.layout()->lineForTextPosition(cursor.positionInBlock()); 1899 | QRectF lineRect = line.rect(); 1900 | lineRect.moveTop(lineRect.top() + r.top()); 1901 | lineRect.setLeft(0.); 1902 | lineRect.setRight(viewportRect.width()); 1903 | painter.fillRect(lineRect.toAlignedRect(), 1904 | currentLineHighlightColor()); 1905 | } 1906 | 1907 | block = block.next(); 1908 | firstVisible = false; 1909 | } 1910 | 1911 | painter.end(); 1912 | QPlainTextEdit::paintEvent(e); 1913 | } 1914 | 1915 | /** 1916 | * Overrides QPlainTextEdit::setReadOnly to fix a problem with Chinese and 1917 | * Japanese input methods 1918 | * 1919 | * @param ro 1920 | */ 1921 | void QMarkdownTextEdit::setReadOnly(bool ro) { 1922 | QPlainTextEdit::setReadOnly(ro); 1923 | 1924 | // attempted to fix a problem with Chinese and Japanese input methods 1925 | // @see https://github.com/pbek/QOwnNotes/issues/976 1926 | setAttribute(Qt::WA_InputMethodEnabled, !isReadOnly()); 1927 | } 1928 | 1929 | void QMarkdownTextEdit::doSearch( 1930 | QString &searchText, QPlainTextEditSearchWidget::SearchMode searchMode) { 1931 | _searchWidget->setSearchText(searchText); 1932 | _searchWidget->setSearchMode(searchMode); 1933 | _searchWidget->doSearchCount(); 1934 | _searchWidget->activate(false); 1935 | } 1936 | 1937 | void QMarkdownTextEdit::hideSearchWidget(bool reset) { 1938 | _searchWidget->deactivate(); 1939 | 1940 | if (reset) { 1941 | _searchWidget->reset(); 1942 | } 1943 | } 1944 | 1945 | void QMarkdownTextEdit::updateSettings() { 1946 | // if true: centers the screen if cursor reaches bottom (but not top) 1947 | searchWidget()->setDebounceDelay(_debounceDelay); 1948 | setCenterOnScroll(_centerCursor); 1949 | } 1950 | 1951 | void QMarkdownTextEdit::setLineNumberLeftMarginOffset(int offset) { 1952 | _lineNumberLeftMarginOffset = offset; 1953 | } 1954 | 1955 | QMargins QMarkdownTextEdit::viewportMargins() { 1956 | return QPlainTextEdit::viewportMargins(); 1957 | } 1958 | -------------------------------------------------------------------------------- /qmarkdowntextedit.h: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include 28 | #include 29 | 30 | #include "markdownhighlighter.h" 31 | #include "qplaintexteditsearchwidget.h" 32 | 33 | class LineNumArea; 34 | 35 | class QMarkdownTextEdit : public QPlainTextEdit { 36 | Q_OBJECT 37 | Q_PROPERTY( 38 | bool highlighting READ highlightingEnabled WRITE setHighlightingEnabled) 39 | 40 | friend class LineNumArea; 41 | 42 | public: 43 | enum AutoTextOption { 44 | None = 0x0000, 45 | 46 | // inserts closing characters for brackets and Markdown characters 47 | BracketClosing = 0x0001, 48 | 49 | // removes matching brackets and Markdown characters 50 | BracketRemoval = 0x0002 51 | }; 52 | 53 | Q_DECLARE_FLAGS(AutoTextOptions, AutoTextOption) 54 | 55 | explicit QMarkdownTextEdit(QWidget *parent = nullptr, 56 | bool initHighlighter = true); 57 | MarkdownHighlighter *highlighter(); 58 | QPlainTextEditSearchWidget *searchWidget(); 59 | void setIgnoredClickUrlSchemata(QStringList ignoredUrlSchemata); 60 | virtual void openUrl(const QString &urlString); 61 | QString getMarkdownUrlAtPosition(const QString &text, int position); 62 | void initSearchFrame(QWidget *searchFrame, bool darkMode = false); 63 | void setAutoTextOptions(AutoTextOptions options); 64 | static bool isValidUrl(const QString &urlString); 65 | void resetMouseCursor() const; 66 | void setReadOnly(bool ro); 67 | void doSearch(QString &searchText, 68 | QPlainTextEditSearchWidget::SearchMode searchMode = 69 | QPlainTextEditSearchWidget::SearchMode::PlainTextMode); 70 | void hideSearchWidget(bool reset); 71 | void updateSettings(); 72 | void setLineNumbersCurrentLineColor(QColor color); 73 | void setLineNumbersOtherLineColor(QColor color); 74 | void setSearchWidgetDebounceDelay(uint debounceDelay); 75 | 76 | void setHighlightingEnabled(bool enabled); 77 | [[nodiscard]] bool highlightingEnabled() const; 78 | 79 | void setHighlightCurrentLine(bool set); 80 | bool highlightCurrentLine(); 81 | 82 | void setCurrentLineHighlightColor(const QColor &c); 83 | QColor currentLineHighlightColor(); 84 | 85 | public Q_SLOTS: 86 | void duplicateText(); 87 | void setText(const QString &text); 88 | void setPlainText(const QString &text); 89 | void adjustRightMargin(); 90 | void hide(); 91 | bool openLinkAtCursorPosition(); 92 | bool handleBackspaceEntered(); 93 | void centerTheCursor(); 94 | void undo(); 95 | void moveTextUpDown(bool up); 96 | void setLineNumberEnabled(bool enabled); 97 | 98 | protected: 99 | QTextCursor _textCursor; 100 | MarkdownHighlighter *_highlighter = nullptr; 101 | bool _highlightingEnabled; 102 | QStringList _ignoredClickUrlSchemata; 103 | QPlainTextEditSearchWidget *_searchWidget; 104 | QWidget *_searchFrame; 105 | AutoTextOptions _autoTextOptions; 106 | bool _mouseButtonDown = false; 107 | bool _centerCursor = false; 108 | bool _highlightCurrentLine = false; 109 | QColor _currentLineHighlightColor = QColor(); 110 | uint _debounceDelay = 0; 111 | 112 | bool eventFilter(QObject *obj, QEvent *event) override; 113 | QMargins viewportMargins(); 114 | bool increaseSelectedTextIndention( 115 | bool reverse, 116 | const QString &indentCharacters = QChar::fromLatin1('\t')); 117 | bool handleTabEntered(bool reverse, const QString &indentCharacters = 118 | QChar::fromLatin1('\t')); 119 | QMap parseMarkdownUrlsFromText(const QString &text); 120 | bool handleReturnEntered(); 121 | bool handleBracketClosing(const QChar openingCharacter, 122 | QChar closingCharacter = QChar()); 123 | bool bracketClosingCheck(const QChar openingCharacter, 124 | QChar closingCharacter); 125 | bool quotationMarkCheck(const QChar quotationCharacter); 126 | void focusOutEvent(QFocusEvent *event) override; 127 | void paintEvent(QPaintEvent *e) override; 128 | bool handleCharRemoval(MarkdownHighlighter::RangeType type, int block, 129 | int position); 130 | void resizeEvent(QResizeEvent *event) override; 131 | void setLineNumberLeftMarginOffset(int offset); 132 | int _lineNumberLeftMarginOffset = 0; 133 | LineNumArea *lineNumberArea() { return _lineNumArea; } 134 | void updateLineNumAreaGeometry(); 135 | void updateLineNumberArea(const QRect rect, int dy); 136 | Q_SLOT void updateLineNumberAreaWidth(int); 137 | bool _handleBracketClosingUsed; 138 | LineNumArea *_lineNumArea; 139 | 140 | Q_SIGNALS: 141 | void urlClicked(QString url); 142 | void zoomIn(); 143 | void zoomOut(); 144 | }; 145 | -------------------------------------------------------------------------------- /qmarkdowntextedit.pc.in: -------------------------------------------------------------------------------- 1 | prefix=@CMAKE_INSTALL_PREFIX@ 2 | exec_prefix=@CMAKE_INSTALL_PREFIX@ 3 | libdir=${exec_prefix}/@CMAKE_INSTALL_LIBDIR@ 4 | includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@/ 5 | 6 | Name: @PROJECT_NAME@ 7 | Description: @PROJECT_DESCRIPTION@ 8 | Version: @PROJECT_VERSION@ 9 | 10 | Requires: 11 | Libs: -L${libdir} -lqmarkdowntextedit 12 | Cflags: -I${includedir} 13 | -------------------------------------------------------------------------------- /qmarkdowntextedit.pri: -------------------------------------------------------------------------------- 1 | INCLUDEPATH += $$PWD/ 2 | 3 | include($$PWD/qmarkdowntextedit-headers.pri) 4 | include($$PWD/qmarkdowntextedit-sources.pri) 5 | -------------------------------------------------------------------------------- /qmarkdowntextedit.pro: -------------------------------------------------------------------------------- 1 | TEMPLATE = subdirs 2 | SUBDIRS = app lib 3 | app.file = qmarkdowntextedit-app.pro 4 | lib.file = qmarkdowntextedit-lib.pro 5 | app.depends = lib 6 | -------------------------------------------------------------------------------- /qownlanguagedata.h: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2019-2021 Waqar Ahmed -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | #ifndef QOWNLANGUAGEDATA_H 25 | #define QOWNLANGUAGEDATA_H 26 | 27 | #include 28 | 29 | /* ------------------------ 30 | * TEMPLATE FOR LANG DATA 31 | * ------------------------- 32 | * 33 | * loadXXXData, where XXX is the language 34 | * keywords are the language keywords e.g, const 35 | * types are built-in types i.e, int, char, var 36 | * literals are words like, true false 37 | * builtin are the library functions 38 | * other can contain any other thing, for e.g, in cpp it contains the 39 | preprocessor 40 | 41 | static const QMultiHash xxx_keywords = { 42 | }; 43 | 44 | static const QMultiHash xxx_types = { 45 | }; 46 | 47 | static const QMultiHash xxx_literals = { 48 | }; 49 | 50 | static const QMultiHash xxx_builtin = { 51 | }; 52 | 53 | static const QMultiHash xxx_other = { 54 | }; 55 | 56 | */ 57 | 58 | /**********************************************************/ 59 | /* C/C++ Data *********************************************/ 60 | /**********************************************************/ 61 | void loadCppData(QMultiHash &typess, 62 | QMultiHash &keywordss, 63 | QMultiHash &builtins, 64 | QMultiHash &literalss, 65 | QMultiHash &others); 66 | 67 | /**********************************************************/ 68 | /* Shell Data *********************************************/ 69 | /**********************************************************/ 70 | void loadShellData(QMultiHash &types, 71 | QMultiHash &keywords, 72 | QMultiHash &builtin, 73 | QMultiHash &literals, 74 | QMultiHash &other); 75 | 76 | /**********************************************************/ 77 | /* JS Data *********************************************/ 78 | /**********************************************************/ 79 | void loadJSData(QMultiHash &types, 80 | QMultiHash &keywords, 81 | QMultiHash &builtin, 82 | QMultiHash &literals, 83 | QMultiHash &other); 84 | 85 | /**********************************************************/ 86 | /* JS Data *********************************************/ 87 | /**********************************************************/ 88 | void loadNixData(QMultiHash &types, 89 | QMultiHash &keywords, 90 | QMultiHash &builtin, 91 | QMultiHash &literals, 92 | QMultiHash &other); 93 | 94 | /**********************************************************/ 95 | /* PHP Data *********************************************/ 96 | /**********************************************************/ 97 | void loadPHPData(QMultiHash &types, 98 | QMultiHash &keywords, 99 | QMultiHash &builtin, 100 | QMultiHash &literals, 101 | QMultiHash &other); 102 | 103 | /**********************************************************/ 104 | /* QML Data *********************************************/ 105 | /**********************************************************/ 106 | void loadQMLData(QMultiHash &types, 107 | QMultiHash &keywords, 108 | QMultiHash &builtin, 109 | QMultiHash &literals, 110 | QMultiHash &other); 111 | 112 | /**********************************************************/ 113 | /* Python Data *********************************************/ 114 | /**********************************************************/ 115 | void loadPythonData(QMultiHash &types, 116 | QMultiHash &keywords, 117 | QMultiHash &builtin, 118 | QMultiHash &literals, 119 | QMultiHash &other); 120 | 121 | /********************************************************/ 122 | /*** Rust DATA ***********************************/ 123 | /********************************************************/ 124 | void loadRustData(QMultiHash &types, 125 | QMultiHash &keywords, 126 | QMultiHash &builtin, 127 | QMultiHash &literals, 128 | QMultiHash &other); 129 | 130 | /********************************************************/ 131 | /*** Java DATA ***********************************/ 132 | /********************************************************/ 133 | void loadJavaData(QMultiHash &types, 134 | QMultiHash &keywords, 135 | QMultiHash &builtin, 136 | QMultiHash &literals, 137 | QMultiHash &other); 138 | 139 | /********************************************************/ 140 | /*** C# DATA *************************************/ 141 | /********************************************************/ 142 | void loadCSharpData(QMultiHash &types, 143 | QMultiHash &keywords, 144 | QMultiHash &builtin, 145 | QMultiHash &literals, 146 | QMultiHash &other); 147 | 148 | /********************************************************/ 149 | /*** Go DATA *************************************/ 150 | /********************************************************/ 151 | void loadGoData(QMultiHash &types, 152 | QMultiHash &keywords, 153 | QMultiHash &builtin, 154 | QMultiHash &literals, 155 | QMultiHash &other); 156 | 157 | /********************************************************/ 158 | /*** V DATA **************************************/ 159 | /********************************************************/ 160 | void loadVData(QMultiHash &types, 161 | QMultiHash &keywords, 162 | QMultiHash &builtin, 163 | QMultiHash &literals, 164 | QMultiHash &other); 165 | 166 | /********************************************************/ 167 | /*** SQL DATA ************************************/ 168 | /********************************************************/ 169 | void loadSQLData(QMultiHash &types, 170 | QMultiHash &keywords, 171 | QMultiHash &builtin, 172 | QMultiHash &literals, 173 | QMultiHash &other); 174 | 175 | /********************************************************/ 176 | /*** System Verilog DATA *************************/ 177 | /********************************************************/ 178 | void loadSystemVerilogData(QMultiHash &types, 179 | QMultiHash &keywords, 180 | QMultiHash &builtin, 181 | QMultiHash &literals, 182 | QMultiHash &other); 183 | 184 | /********************************************************/ 185 | /*** JSON DATA ***********************************/ 186 | /********************************************************/ 187 | void loadJSONData(QMultiHash &types, 188 | QMultiHash &keywords, 189 | QMultiHash &builtin, 190 | QMultiHash &literals, 191 | QMultiHash &other); 192 | 193 | /********************************************************/ 194 | /*** CSS DATA ***********************************/ 195 | /********************************************************/ 196 | void loadCSSData(QMultiHash &types, 197 | QMultiHash &keywords, 198 | QMultiHash &builtin, 199 | QMultiHash &literals, 200 | QMultiHash &other); 201 | 202 | /********************************************************/ 203 | /*** Typescript DATA *********************************/ 204 | /********************************************************/ 205 | void loadTypescriptData(QMultiHash &types, 206 | QMultiHash &keywords, 207 | QMultiHash &builtin, 208 | QMultiHash &literals, 209 | QMultiHash &other); 210 | 211 | /********************************************************/ 212 | /*** YAML DATA ***************************************/ 213 | /********************************************************/ 214 | void loadYAMLData(QMultiHash &types, 215 | QMultiHash &keywords, 216 | QMultiHash &builtin, 217 | QMultiHash &literals, 218 | QMultiHash &other); 219 | 220 | /********************************************************/ 221 | /*** VEX DATA ****************************************/ 222 | /********************************************************/ 223 | void loadVEXData(QMultiHash &types, 224 | QMultiHash &keywords, 225 | QMultiHash &builtin, 226 | QMultiHash &literals, 227 | QMultiHash &other); 228 | 229 | /********************************************************/ 230 | /*** CMake DATA **************************************/ 231 | /********************************************************/ 232 | void loadCMakeData(QMultiHash &types, 233 | QMultiHash &keywords, 234 | QMultiHash &builtin, 235 | QMultiHash &literals, 236 | QMultiHash &other); 237 | 238 | /********************************************************/ 239 | /*** Make DATA ***************************************/ 240 | /********************************************************/ 241 | void loadMakeData(QMultiHash &types, 242 | QMultiHash &keywords, 243 | QMultiHash &builtin, 244 | QMultiHash &literals, 245 | QMultiHash &other); 246 | /********************************************************/ 247 | /*** Forth DATA **************************************/ 248 | /********************************************************/ 249 | void loadForthData(QMultiHash &types, 250 | QMultiHash &keywords, 251 | QMultiHash &builtin, 252 | QMultiHash &literals, 253 | QMultiHash &other); 254 | /********************************************************/ 255 | /*** GDScript DATA **************************************/ 256 | /********************************************************/ 257 | void loadGDScriptData(QMultiHash &types, 258 | QMultiHash &keywords, 259 | QMultiHash &builtin, 260 | QMultiHash &literals, 261 | QMultiHash &other); 262 | /********************************************************/ 263 | /*** TOML DATA **************************************/ 264 | /********************************************************/ 265 | void loadTOMLData(QMultiHash &types, 266 | QMultiHash &keywords, 267 | QMultiHash &builtin, 268 | QMultiHash &literals, 269 | QMultiHash &other); 270 | #endif 271 | -------------------------------------------------------------------------------- /qplaintexteditsearchwidget.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #include "qplaintexteditsearchwidget.h" 26 | 27 | #include 28 | #include 29 | #include 30 | 31 | #include "ui_qplaintexteditsearchwidget.h" 32 | 33 | QPlainTextEditSearchWidget::QPlainTextEditSearchWidget(QPlainTextEdit *parent) 34 | : QWidget(parent), 35 | ui(new Ui::QPlainTextEditSearchWidget), 36 | selectionColor(0, 180, 0, 100) { 37 | ui->setupUi(this); 38 | _textEdit = parent; 39 | _darkMode = false; 40 | hide(); 41 | ui->searchCountLabel->setStyleSheet(QStringLiteral("* {color: grey}")); 42 | // hiding will leave a open space in the horizontal layout 43 | ui->searchCountLabel->setEnabled(false); 44 | _currentSearchResult = 0; 45 | _searchResultCount = 0; 46 | 47 | connect(ui->closeButton, &QPushButton::clicked, this, 48 | &QPlainTextEditSearchWidget::deactivate); 49 | connect(ui->searchLineEdit, &QLineEdit::textChanged, this, 50 | &QPlainTextEditSearchWidget::searchLineEditTextChanged); 51 | connect(ui->searchDownButton, &QPushButton::clicked, this, 52 | &QPlainTextEditSearchWidget::doSearchDown); 53 | connect(ui->searchUpButton, &QPushButton::clicked, this, 54 | &QPlainTextEditSearchWidget::doSearchUp); 55 | connect(ui->replaceToggleButton, &QPushButton::toggled, this, 56 | &QPlainTextEditSearchWidget::setReplaceMode); 57 | connect(ui->replaceButton, &QPushButton::clicked, this, 58 | &QPlainTextEditSearchWidget::doReplace); 59 | connect(ui->replaceAllButton, &QPushButton::clicked, this, 60 | &QPlainTextEditSearchWidget::doReplaceAll); 61 | 62 | connect(&_debounceTimer, &QTimer::timeout, this, 63 | &QPlainTextEditSearchWidget::performSearch); 64 | 65 | installEventFilter(this); 66 | ui->searchLineEdit->installEventFilter(this); 67 | ui->replaceLineEdit->installEventFilter(this); 68 | 69 | #ifdef Q_OS_MAC 70 | // set the spacing to 8 for OS X 71 | layout()->setSpacing(8); 72 | ui->buttonFrame->layout()->setSpacing(9); 73 | 74 | // set the margin to 0 for the top buttons for OS X 75 | QString buttonStyle = QStringLiteral("QPushButton {margin: 0}"); 76 | ui->closeButton->setStyleSheet(buttonStyle); 77 | ui->searchDownButton->setStyleSheet(buttonStyle); 78 | ui->searchUpButton->setStyleSheet(buttonStyle); 79 | ui->replaceToggleButton->setStyleSheet(buttonStyle); 80 | ui->matchCaseSensitiveButton->setStyleSheet(buttonStyle); 81 | #endif 82 | } 83 | 84 | QPlainTextEditSearchWidget::~QPlainTextEditSearchWidget() { delete ui; } 85 | 86 | void QPlainTextEditSearchWidget::activate() { activate(true); } 87 | 88 | void QPlainTextEditSearchWidget::activateReplace() { 89 | // replacing is prohibited if the text edit is readonly 90 | if (_textEdit->isReadOnly()) { 91 | return; 92 | } 93 | 94 | ui->searchLineEdit->setText(_textEdit->textCursor().selectedText()); 95 | ui->searchLineEdit->selectAll(); 96 | activate(); 97 | setReplaceMode(true); 98 | } 99 | 100 | void QPlainTextEditSearchWidget::deactivate() { 101 | stopDebounce(); 102 | 103 | hide(); 104 | 105 | // Clear the search extra selections when closing the search bar 106 | clearSearchExtraSelections(); 107 | 108 | _textEdit->setFocus(); 109 | } 110 | 111 | void QPlainTextEditSearchWidget::setReplaceMode(bool enabled) { 112 | ui->replaceToggleButton->setChecked(enabled); 113 | ui->replaceLabel->setVisible(enabled); 114 | ui->replaceLineEdit->setVisible(enabled); 115 | ui->modeLabel->setVisible(enabled); 116 | ui->buttonFrame->setVisible(enabled); 117 | ui->matchCaseSensitiveButton->setVisible(enabled); 118 | } 119 | 120 | bool QPlainTextEditSearchWidget::eventFilter(QObject *obj, QEvent *event) { 121 | if (event->type() == QEvent::KeyPress) { 122 | auto *keyEvent = static_cast(event); 123 | 124 | if (keyEvent->key() == Qt::Key_Escape) { 125 | deactivate(); 126 | return true; 127 | } else if ((!_debounceTimer.isActive() && 128 | keyEvent->modifiers().testFlag(Qt::ShiftModifier) && 129 | (keyEvent->key() == Qt::Key_Return)) || 130 | (keyEvent->key() == Qt::Key_Up)) { 131 | doSearchUp(); 132 | return true; 133 | } else if (!_debounceTimer.isActive() && 134 | ((keyEvent->key() == Qt::Key_Return) || 135 | (keyEvent->key() == Qt::Key_Down))) { 136 | doSearchDown(); 137 | return true; 138 | } else if (!_debounceTimer.isActive() && 139 | keyEvent->key() == Qt::Key_F3) { 140 | doSearch(!keyEvent->modifiers().testFlag(Qt::ShiftModifier)); 141 | return true; 142 | } 143 | 144 | // if ((obj == ui->replaceLineEdit) && (keyEvent->key() == 145 | // Qt::Key_Tab) 146 | // && ui->replaceToggleButton->isChecked()) { 147 | // ui->replaceLineEdit->setFocus(); 148 | // } 149 | 150 | return false; 151 | } 152 | 153 | return QWidget::eventFilter(obj, event); 154 | } 155 | 156 | void QPlainTextEditSearchWidget::searchLineEditTextChanged( 157 | const QString &arg1) { 158 | _searchTerm = arg1; 159 | 160 | if (_debounceTimer.interval() != 0 && !_searchTerm.isEmpty()) { 161 | _debounceTimer.start(); 162 | ui->searchDownButton->setEnabled(false); 163 | ui->searchUpButton->setEnabled(false); 164 | } else { 165 | performSearch(); 166 | } 167 | } 168 | 169 | void QPlainTextEditSearchWidget::performSearch() { 170 | doSearchCount(); 171 | updateSearchExtraSelections(); 172 | doSearchDown(); 173 | } 174 | 175 | void QPlainTextEditSearchWidget::clearSearchExtraSelections() { 176 | _searchExtraSelections.clear(); 177 | setSearchExtraSelections(); 178 | } 179 | 180 | void QPlainTextEditSearchWidget::updateSearchExtraSelections() { 181 | _searchExtraSelections.clear(); 182 | const auto textCursor = _textEdit->textCursor(); 183 | _textEdit->moveCursor(QTextCursor::Start); 184 | const QColor color = selectionColor; 185 | QTextCharFormat extraFmt; 186 | extraFmt.setBackground(color); 187 | int findCounter = 0; 188 | const int searchMode = ui->modeComboBox->currentIndex(); 189 | 190 | while (doSearch(true, false, false)) { 191 | findCounter++; 192 | 193 | // prevent infinite loops from regular expression searches like "$", "^" 194 | // or "\b" 195 | if (searchMode == RegularExpressionMode && findCounter >= 10000) { 196 | break; 197 | } 198 | 199 | QTextEdit::ExtraSelection extra = QTextEdit::ExtraSelection(); 200 | extra.format = extraFmt; 201 | 202 | extra.cursor = _textEdit->textCursor(); 203 | _searchExtraSelections.append(extra); 204 | } 205 | 206 | _textEdit->setTextCursor(textCursor); 207 | this->setSearchExtraSelections(); 208 | } 209 | 210 | void QPlainTextEditSearchWidget::setSearchExtraSelections() const { 211 | this->_textEdit->setExtraSelections(this->_searchExtraSelections); 212 | } 213 | 214 | void QPlainTextEditSearchWidget::stopDebounce() { 215 | _debounceTimer.stop(); 216 | ui->searchDownButton->setEnabled(true); 217 | ui->searchUpButton->setEnabled(true); 218 | } 219 | 220 | void QPlainTextEditSearchWidget::doSearchUp() { doSearch(false); } 221 | 222 | void QPlainTextEditSearchWidget::doSearchDown() { doSearch(true); } 223 | 224 | bool QPlainTextEditSearchWidget::doReplace(bool forAll) { 225 | if (_textEdit->isReadOnly()) { 226 | return false; 227 | } 228 | 229 | QTextCursor cursor = _textEdit->textCursor(); 230 | 231 | if (!forAll && cursor.selectedText().isEmpty()) { 232 | return false; 233 | } 234 | 235 | const int searchMode = ui->modeComboBox->currentIndex(); 236 | if (searchMode == RegularExpressionMode) { 237 | QString text = cursor.selectedText(); 238 | text.replace(QRegularExpression(ui->searchLineEdit->text()), 239 | ui->replaceLineEdit->text()); 240 | cursor.insertText(text); 241 | } else { 242 | cursor.insertText(ui->replaceLineEdit->text()); 243 | } 244 | 245 | if (!forAll) { 246 | const int position = cursor.position(); 247 | 248 | if (!doSearch(true)) { 249 | // restore the last cursor position if text wasn't found any more 250 | cursor.setPosition(position); 251 | _textEdit->setTextCursor(cursor); 252 | } 253 | } 254 | 255 | return true; 256 | } 257 | 258 | void QPlainTextEditSearchWidget::doReplaceAll() { 259 | if (_textEdit->isReadOnly()) { 260 | return; 261 | } 262 | 263 | // start at the top 264 | _textEdit->moveCursor(QTextCursor::Start); 265 | 266 | // replace until everything to the bottom is replaced 267 | while (doSearch(true, false) && doReplace(true)) { 268 | } 269 | } 270 | 271 | /** 272 | * @brief Searches for text in the text edit 273 | * @returns true if found 274 | */ 275 | bool QPlainTextEditSearchWidget::doSearch(bool searchDown, 276 | bool allowRestartAtTop, 277 | bool updateUI) { 278 | if (_debounceTimer.isActive()) { 279 | stopDebounce(); 280 | } 281 | 282 | const QString text = ui->searchLineEdit->text(); 283 | 284 | if (text.isEmpty()) { 285 | if (updateUI) { 286 | ui->searchLineEdit->setStyleSheet(QLatin1String("")); 287 | } 288 | 289 | return false; 290 | } 291 | 292 | const int searchMode = ui->modeComboBox->currentIndex(); 293 | const bool caseSensitive = ui->matchCaseSensitiveButton->isChecked(); 294 | 295 | QFlags options = 296 | searchDown ? QTextDocument::FindFlag(0) : QTextDocument::FindBackward; 297 | if (searchMode == WholeWordsMode) { 298 | options |= QTextDocument::FindWholeWords; 299 | } 300 | 301 | if (caseSensitive) { 302 | options |= QTextDocument::FindCaseSensitively; 303 | } 304 | 305 | // block signal to reduce too many signals being fired and too many updates 306 | _textEdit->blockSignals(true); 307 | 308 | bool found = 309 | searchMode == RegularExpressionMode 310 | ? 311 | #if (QT_VERSION >= QT_VERSION_CHECK(5, 13, 0)) 312 | _textEdit->find( 313 | QRegularExpression( 314 | text, caseSensitive 315 | ? QRegularExpression::NoPatternOption 316 | : QRegularExpression::CaseInsensitiveOption), 317 | options) 318 | : 319 | #else 320 | _textEdit->find(QRegExp(text, caseSensitive ? Qt::CaseSensitive 321 | : Qt::CaseInsensitive), 322 | options) 323 | : 324 | #endif 325 | _textEdit->find(text, options); 326 | 327 | _textEdit->blockSignals(false); 328 | 329 | if (found) { 330 | const int result = 331 | searchDown ? ++_currentSearchResult : --_currentSearchResult; 332 | _currentSearchResult = std::min(result, _searchResultCount); 333 | 334 | updateSearchCountLabelText(); 335 | } 336 | 337 | // start at the top (or bottom) if not found 338 | if (!found && allowRestartAtTop) { 339 | _textEdit->moveCursor(searchDown ? QTextCursor::Start 340 | : QTextCursor::End); 341 | found = 342 | searchMode == RegularExpressionMode 343 | ? 344 | #if (QT_VERSION >= QT_VERSION_CHECK(5, 13, 0)) 345 | _textEdit->find( 346 | QRegularExpression( 347 | text, caseSensitive 348 | ? QRegularExpression::NoPatternOption 349 | : QRegularExpression::CaseInsensitiveOption), 350 | options) 351 | : 352 | #else 353 | _textEdit->find( 354 | QRegExp(text, caseSensitive ? Qt::CaseSensitive 355 | : Qt::CaseInsensitive), 356 | options) 357 | : 358 | #endif 359 | _textEdit->find(text, options); 360 | 361 | if (found && updateUI) { 362 | _currentSearchResult = searchDown ? 1 : _searchResultCount; 363 | updateSearchCountLabelText(); 364 | } 365 | } 366 | 367 | if (updateUI) { 368 | const QRect rect = _textEdit->cursorRect(); 369 | QMargins margins = _textEdit->layout()->contentsMargins(); 370 | const int searchWidgetHotArea = _textEdit->height() - this->height(); 371 | const int marginBottom = 372 | (rect.y() > searchWidgetHotArea) ? (this->height() + 10) : 0; 373 | 374 | // move the search box a bit up if we would block the search result 375 | if (margins.bottom() != marginBottom) { 376 | margins.setBottom(marginBottom); 377 | _textEdit->layout()->setContentsMargins(margins); 378 | } 379 | 380 | // add a background color according if we found the text or not 381 | const QString bgColorCode = _darkMode 382 | ? (found ? QStringLiteral("#135a13") 383 | : QStringLiteral("#8d2b36")) 384 | : found ? QStringLiteral("#D5FAE2") 385 | : QStringLiteral("#FAE9EB"); 386 | const QString fgColorCode = 387 | _darkMode ? QStringLiteral("#cccccc") : QStringLiteral("#404040"); 388 | 389 | ui->searchLineEdit->setStyleSheet( 390 | QStringLiteral("* { background: ") + bgColorCode + 391 | QStringLiteral("; color: ") + fgColorCode + QStringLiteral("; }")); 392 | 393 | // restore the search extra selections after the find command 394 | this->setSearchExtraSelections(); 395 | } 396 | 397 | return found; 398 | } 399 | 400 | /** 401 | * @brief Counts the search results 402 | */ 403 | void QPlainTextEditSearchWidget::doSearchCount() { 404 | // Note that we are moving the anchor, so the search will start from the top 405 | // again! Alternative: Restore cursor position afterward, but then we will 406 | // not know 407 | // at what _currentSearchResult we currently are 408 | _textEdit->moveCursor(QTextCursor::Start, QTextCursor::MoveAnchor); 409 | 410 | bool found; 411 | _searchResultCount = 0; 412 | _currentSearchResult = 0; 413 | const int searchMode = ui->modeComboBox->currentIndex(); 414 | 415 | do { 416 | found = doSearch(true, false, false); 417 | if (found) { 418 | _searchResultCount++; 419 | } 420 | 421 | // prevent infinite loops from regular expression searches like "$", "^" 422 | // or "\b" 423 | if (searchMode == RegularExpressionMode && 424 | _searchResultCount >= 10000) { 425 | break; 426 | } 427 | } while (found); 428 | 429 | updateSearchCountLabelText(); 430 | } 431 | 432 | void QPlainTextEditSearchWidget::setDarkMode(bool enabled) { 433 | _darkMode = enabled; 434 | } 435 | 436 | void QPlainTextEditSearchWidget::setSearchText(const QString &searchText) { 437 | ui->searchLineEdit->setText(searchText); 438 | } 439 | 440 | void QPlainTextEditSearchWidget::setSearchMode(SearchMode searchMode) { 441 | ui->modeComboBox->setCurrentIndex(searchMode); 442 | } 443 | 444 | void QPlainTextEditSearchWidget::setDebounceDelay(uint debounceDelay) { 445 | _debounceTimer.setInterval(static_cast(debounceDelay)); 446 | } 447 | 448 | void QPlainTextEditSearchWidget::activate(bool focus) { 449 | setReplaceMode(ui->modeComboBox->currentIndex() != 450 | SearchMode::PlainTextMode); 451 | show(); 452 | 453 | // preset the selected text as search text if there is any and there is no 454 | // other search text 455 | const QString selectedText = _textEdit->textCursor().selectedText(); 456 | if (!selectedText.isEmpty() && ui->searchLineEdit->text().isEmpty()) { 457 | ui->searchLineEdit->setText(selectedText); 458 | } 459 | 460 | if (focus) { 461 | ui->searchLineEdit->setFocus(); 462 | } 463 | 464 | ui->searchLineEdit->selectAll(); 465 | updateSearchExtraSelections(); 466 | doSearchDown(); 467 | } 468 | 469 | void QPlainTextEditSearchWidget::reset() { 470 | ui->searchLineEdit->clear(); 471 | setSearchMode(SearchMode::PlainTextMode); 472 | setReplaceMode(false); 473 | ui->searchCountLabel->setEnabled(false); 474 | } 475 | 476 | void QPlainTextEditSearchWidget::updateSearchCountLabelText() { 477 | ui->searchCountLabel->setEnabled(true); 478 | ui->searchCountLabel->setText(QStringLiteral("%1/%2").arg( 479 | _currentSearchResult == 0 ? QChar('-') 480 | : QString::number(_currentSearchResult), 481 | _searchResultCount == 0 ? QChar('-') 482 | : QString::number(_searchResultCount))); 483 | } 484 | 485 | void QPlainTextEditSearchWidget::setSearchSelectionColor(const QColor &color) { 486 | selectionColor = color; 487 | } 488 | 489 | void QPlainTextEditSearchWidget::on_modeComboBox_currentIndexChanged( 490 | int index) { 491 | Q_UNUSED(index) 492 | doSearchCount(); 493 | doSearchDown(); 494 | } 495 | 496 | void QPlainTextEditSearchWidget::on_matchCaseSensitiveButton_toggled( 497 | bool checked) { 498 | Q_UNUSED(checked) 499 | doSearchCount(); 500 | doSearchDown(); 501 | } 502 | -------------------------------------------------------------------------------- /qplaintexteditsearchwidget.h: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2014-2025 Patrizio Bekerle -- 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include 28 | #include 29 | #include 30 | 31 | namespace Ui { 32 | class QPlainTextEditSearchWidget; 33 | } 34 | 35 | class QPlainTextEditSearchWidget : public QWidget { 36 | Q_OBJECT 37 | 38 | public: 39 | enum SearchMode { PlainTextMode, WholeWordsMode, RegularExpressionMode }; 40 | 41 | explicit QPlainTextEditSearchWidget(QPlainTextEdit *parent = nullptr); 42 | bool doSearch(bool searchDown = true, bool allowRestartAtTop = true, 43 | bool updateUI = true); 44 | void setDarkMode(bool enabled); 45 | ~QPlainTextEditSearchWidget(); 46 | 47 | void setSearchText(const QString &searchText); 48 | void setSearchMode(SearchMode searchMode); 49 | void setDebounceDelay(uint debounceDelay); 50 | void activate(bool focus); 51 | void clearSearchExtraSelections(); 52 | void updateSearchExtraSelections(); 53 | 54 | private: 55 | Ui::QPlainTextEditSearchWidget *ui; 56 | int _searchResultCount; 57 | int _currentSearchResult; 58 | QList _searchExtraSelections; 59 | QColor selectionColor; 60 | QTimer _debounceTimer; 61 | QString _searchTerm; 62 | void setSearchExtraSelections() const; 63 | void stopDebounce(); 64 | 65 | protected: 66 | QPlainTextEdit *_textEdit; 67 | bool _darkMode; 68 | bool eventFilter(QObject *obj, QEvent *event) override; 69 | 70 | public Q_SLOTS: 71 | void activate(); 72 | void deactivate(); 73 | void doSearchDown(); 74 | void doSearchUp(); 75 | void setReplaceMode(bool enabled); 76 | void activateReplace(); 77 | bool doReplace(bool forAll = false); 78 | void doReplaceAll(); 79 | void reset(); 80 | void doSearchCount(); 81 | 82 | protected Q_SLOTS: 83 | void searchLineEditTextChanged(const QString &arg1); 84 | void performSearch(); 85 | void updateSearchCountLabelText(); 86 | void setSearchSelectionColor(const QColor &color); 87 | private Q_SLOTS: 88 | void on_modeComboBox_currentIndexChanged(int index); 89 | void on_matchCaseSensitiveButton_toggled(bool checked); 90 | }; 91 | -------------------------------------------------------------------------------- /qplaintexteditsearchwidget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QPlainTextEditSearchWidget 4 | 5 | 6 | 7 | 0 8 | 0 9 | 836 10 | 142 11 | 12 | 13 | 14 | true 15 | 16 | 17 | 18 | 0 19 | 20 | 21 | 0 22 | 23 | 24 | 0 25 | 26 | 27 | 0 28 | 29 | 30 | 31 | 32 | Find in text 33 | 34 | 35 | 36 | 37 | 38 | 39 | Replace with 40 | 41 | 42 | 43 | 44 | 45 | 46 | -/- 47 | 48 | 49 | 50 | 51 | 52 | 53 | Find: 54 | 55 | 56 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 57 | 58 | 59 | 60 | 61 | 62 | 63 | Search backward 64 | 65 | 66 | 67 | 68 | 69 | 70 | :/media/go-top.svg:/media/go-top.svg 71 | 72 | 73 | true 74 | 75 | 76 | 77 | 78 | 79 | 80 | Replace: 81 | 82 | 83 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 84 | 85 | 86 | 87 | 88 | 89 | 90 | Close search 91 | 92 | 93 | 94 | 95 | 96 | 97 | :/media/window-close.svg:/media/window-close.svg 98 | 99 | 100 | true 101 | 102 | 103 | 104 | 105 | 106 | 107 | Advanced search / replace text 108 | 109 | 110 | 111 | 112 | 113 | 114 | :/media/edit-find-replace.svg:/media/edit-find-replace.svg 115 | 116 | 117 | true 118 | 119 | 120 | true 121 | 122 | 123 | 124 | 125 | 126 | 127 | Search forward 128 | 129 | 130 | 131 | 132 | 133 | 134 | :/media/go-bottom.svg:/media/go-bottom.svg 135 | 136 | 137 | true 138 | 139 | 140 | 141 | 142 | 143 | 144 | Match case sensitive 145 | 146 | 147 | 148 | 149 | 150 | 151 | :/media/format-text-superscript.svg:/media/format-text-superscript.svg 152 | 153 | 154 | true 155 | 156 | 157 | true 158 | 159 | 160 | 161 | 162 | 163 | 164 | Mode: 165 | 166 | 167 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 168 | 169 | 170 | 171 | 172 | 173 | 174 | QFrame::NoFrame 175 | 176 | 177 | 178 | 0 179 | 180 | 181 | 0 182 | 183 | 184 | 0 185 | 186 | 187 | 0 188 | 189 | 190 | 191 | 192 | 193 | Plain text 194 | 195 | 196 | 197 | 198 | Whole words 199 | 200 | 201 | 202 | 203 | Regular expression 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | Qt::Horizontal 212 | 213 | 214 | 215 | 40 216 | 20 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | Replace one text occurrence 225 | 226 | 227 | Replace 228 | 229 | 230 | false 231 | 232 | 233 | 234 | 235 | 236 | 237 | Replace all text occurrences 238 | 239 | 240 | Replace all 241 | 242 | 243 | false 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | searchLineEdit 254 | replaceLineEdit 255 | replaceButton 256 | replaceAllButton 257 | searchDownButton 258 | searchUpButton 259 | replaceToggleButton 260 | closeButton 261 | 262 | 263 | 264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/screenshot.png -------------------------------------------------------------------------------- /scripts/clang-format-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # A tool to run clang-format on the entire project 4 | # 5 | # Some inspirations were taken from https://github.com/eklitzke/clang-format-all 6 | 7 | # Variable that will hold the name of the clang-format command 8 | FMT="" 9 | 10 | # Some distros just call it clang-format. Others (e.g. Ubuntu) are insistent 11 | # that the version number be part of the command. We prefer clang-format if 12 | # that's present, otherwise we work backwards from highest version to lowest 13 | # version. 14 | for clangfmt in clang-format{,-{4,3}.{9,8,7,6,5,4,3,2,1,0}}; do 15 | if which "$clangfmt" &>/dev/null; then 16 | FMT="$clangfmt" 17 | break 18 | fi 19 | done 20 | 21 | # Check if we found a working clang-format 22 | if [ -z "$FMT" ]; then 23 | echo "failed to find clang-format" 24 | exit 1 25 | fi 26 | 27 | $FMT -i *.cpp 28 | $FMT -i *.h 29 | -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_de.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/trans/qmarkdowntextedit_de.qm -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_de.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QPlainTextEditSearchWidget 6 | 7 | 8 | close search 9 | Suche schließen 10 | 11 | 12 | 13 | Find: 14 | Finden: 15 | 16 | 17 | 18 | replace text 19 | Text ersetzen 20 | 21 | 22 | 23 | find in text 24 | im Text finden 25 | 26 | 27 | 28 | search forward 29 | vorwärts suchen 30 | 31 | 32 | 33 | search backward 34 | rückwärts suchen 35 | 36 | 37 | 38 | replace with 39 | ersetzen mit 40 | 41 | 42 | 43 | Replace: 44 | Ersetzen: 45 | 46 | 47 | 48 | Replace 49 | Ersetzen 50 | 51 | 52 | 53 | Replace All 54 | Alle ersetzen 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_es.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/trans/qmarkdowntextedit_es.qm -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_es.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QPlainTextEditSearchWidget 6 | 7 | 8 | Find in text 9 | Buscar en el texto 10 | 11 | 12 | 13 | Replace with 14 | Reemplazar por 15 | 16 | 17 | 18 | Find: 19 | Buscar: 20 | 21 | 22 | 23 | Search backward 24 | Buscar hacia atrás 25 | 26 | 27 | 28 | Replace: 29 | Reemplazar: 30 | 31 | 32 | 33 | Close search 34 | Cerrar búsqueda 35 | 36 | 37 | 38 | Advanced search / replace text 39 | Búsqueda avanzada / reemplazar texto 40 | 41 | 42 | 43 | Search forward 44 | Buscar hacia adelante 45 | 46 | 47 | 48 | Match case sensitive 49 | Distingue mayúsculas y minúsculas 50 | 51 | 52 | 53 | Mode: 54 | Modo: 55 | 56 | 57 | 58 | Plain text 59 | Texto plano 60 | 61 | 62 | 63 | Whole words 64 | Palabras enteras 65 | 66 | 67 | 68 | Regular expression 69 | Expresión regular 70 | 71 | 72 | 73 | Replace one text occurrence 74 | Reemplazar una ocurrencia del texto 75 | 76 | 77 | 78 | Replace 79 | Reemplazar 80 | 81 | 82 | 83 | Replace all text occurrences 84 | Reemplazar todas las ocurrencias del texto 85 | 86 | 87 | 88 | Replace all 89 | Reemplazar todo 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_ur.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/trans/qmarkdowntextedit_ur.qm -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_ur.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QPlainTextEditSearchWidget 6 | 7 | 8 | close search 9 | تلاش بند کریں 10 | 11 | 12 | 13 | Find: 14 | تلاش: 15 | 16 | 17 | 18 | replace text 19 | ٹیکصٹ بدلیں 20 | 21 | 22 | 23 | find in text 24 | متن میں تلاش کریں 25 | 26 | 27 | 28 | search forward 29 | آگے تلاش کریں 30 | 31 | 32 | 33 | search backward 34 | پیچھے تلاش کریں 35 | 36 | 37 | 38 | replace with 39 | بدلیں اس سے 40 | 41 | 42 | 43 | Replace: 44 | بدلیں: 45 | 46 | 47 | 48 | Replace 49 | بدلیں 50 | 51 | 52 | 53 | Replace All 54 | تمام کو بدل دیں 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_zh_CN.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbek/qmarkdowntextedit/6be40e9746e4dace5c39983860c5ef08ce3df581/trans/qmarkdowntextedit_zh_CN.qm -------------------------------------------------------------------------------- /trans/qmarkdowntextedit_zh_CN.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QPlainTextEditSearchWidget 6 | 7 | 8 | Find in text 9 | 在文本中查找 10 | 11 | 12 | 13 | Replace with 14 | 替换为 15 | 16 | 17 | 18 | Find: 19 | 查找 20 | 21 | 22 | 23 | Search backward 24 | 上一个匹配项 25 | 26 | 27 | 28 | Replace: 29 | 替换 30 | 31 | 32 | 33 | Close search 34 | 关闭搜索框 35 | 36 | 37 | 38 | Advanced search / replace text 39 | 高级搜索/替换 40 | 41 | 42 | 43 | Search forward 44 | 下一个匹配项 45 | 46 | 47 | 48 | Match case sensitive 49 | 区分大小写 50 | 51 | 52 | 53 | Mode: 54 | 模式 55 | 56 | 57 | 58 | Plain text 59 | 字符匹配 60 | 61 | 62 | 63 | Whole words 64 | 全字匹配 65 | 66 | 67 | 68 | Regular expression 69 | 正则表达式 70 | 71 | 72 | 73 | Replace one text occurrence 74 | 替换第一个匹配的文本 75 | 76 | 77 | 78 | Replace 79 | 替换 80 | 81 | 82 | 83 | Replace all text occurrences 84 | 替换所有匹配的文本 85 | 86 | 87 | 88 | Replace all 89 | 全部替换 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/numtide/treefmt 2 | # https://github.com/numtide/treefmt-nix 3 | 4 | on-unmatched = "info" 5 | 6 | [formatter.clang-format] 7 | command = "clang-format" 8 | 9 | # Only target the exact directories we want 10 | includes = [ 11 | "*.cpp", 12 | "*.h", 13 | ] 14 | 15 | [formatter.prettier] 16 | command = "prettier" 17 | options = ["--write"] 18 | includes = ["*.md", "*.yaml", "*.yml"] 19 | 20 | [formatter.shfmt] 21 | command = "shfmt" 22 | excludes = [] 23 | includes = ["*.sh", "*.bash", "*.envrc", "*.envrc.*"] 24 | options = ["-s", "-w", "-i", "2"] 25 | 26 | [formatter.just] 27 | command = "just" 28 | includes = ["*.just"] 29 | 30 | [formatter.taplo] 31 | command = "taplo" 32 | includes = ["*.toml"] 33 | options = ["format"] 34 | 35 | [formatter.nixfmt-rfc-style] 36 | command = "nixfmt" 37 | excludes = [] 38 | includes = ["*.nix"] 39 | options = [] 40 | --------------------------------------------------------------------------------