├── .changes ├── unreleased │ └── .gitkeep ├── header.tpl.md ├── 1.3.92.md ├── 1.3.93.md ├── 1.4.1.md ├── 1.4.0.md └── 1.3.91.md ├── ci ├── .gitignore ├── apply-codingstyle ├── README.md ├── lib │ ├── macos-dependencies.sh │ ├── linux-dependencies.sh │ ├── windows-dependencies.sh │ ├── common-dependencies.sh │ └── common.sh ├── headless-test-helper ├── check-codingstyle ├── install-clang-format ├── build-app └── install-dependencies ├── screenshot.png ├── .gitignore ├── third-party ├── CMakeLists.txt ├── README.md └── singleapplication │ └── CMakeLists.txt ├── src ├── appicon │ ├── 256-apps-nanonote.png │ ├── 1024-apps-nanonote.png │ ├── CMakeLists.txt │ └── sc-apps-nanonote.svg ├── app.qrc ├── linux │ ├── nanonote.desktop │ └── nanonote.metainfo.xml ├── translations.qrc ├── Resources.h ├── WheelZoomExtension.h ├── BuildConfig.h.in ├── SettingsDialog.h ├── TaskExtension.h ├── MoveLinesExtension.h ├── SyntaxHighlighter.h ├── LinkExtension.h ├── Settings.h ├── WheelZoomExtension.cpp ├── IndentExtension.h ├── macos │ └── Info.plist.in ├── SearchWidget.h ├── Info.plist.in ├── Resources.cpp ├── TextEdit.h ├── MainWindow.h ├── Settings.cpp ├── SearchWidget.ui ├── main.cpp ├── LinkExtension.cpp ├── SettingsDialog.cpp ├── SyntaxHighlighter.cpp ├── CMakeLists.txt ├── TaskExtension.cpp ├── TextEdit.cpp ├── MoveLinesExtension.cpp ├── SearchWidget.cpp ├── IndentExtension.cpp ├── SettingsDialog.ui └── translations │ ├── nanonote_nl.ts │ ├── nanonote_no.ts │ ├── nanonote_da.ts │ └── nanonote_en.ts ├── .gitmodules ├── tests ├── tests.cpp ├── Catch2QtUtils.cpp ├── CMakeLists.txt ├── TextUtils.h ├── Catch2QtUtils.h ├── SyntaxHighlighterTest.cpp ├── TaskExtensionTest.cpp ├── TextUtils.cpp ├── SearchWidgetTest.cpp ├── IndentExtensionTest.cpp └── MoveLinesExtensionTest.cpp ├── .markdownlint.yml ├── .changie.yaml ├── .github └── workflows │ ├── changelog-check.yml │ ├── tag.yml │ └── main.yml ├── cmake ├── find_python_module.cmake ├── translations.cmake └── DeployQt.cmake ├── .pre-commit-config.yaml ├── docs ├── translations.md ├── tips.md └── release-check-list.md ├── README.md ├── LICENSE ├── .clang-format ├── CMakeLists.txt ├── changelog.py ├── packaging ├── macos │ └── generate-ds-store └── CMakeLists.txt ├── CHANGELOG.md └── tasks.py /.changes/unreleased/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.changes/header.tpl.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /ci/.gitignore: -------------------------------------------------------------------------------- 1 | requirements.txt 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateau/nanonote/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | /artifacts 4 | /inst 5 | CMakeLists.txt.user* 6 | .*.swp 7 | -------------------------------------------------------------------------------- /third-party/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_subdirectory(Catch2) 2 | add_subdirectory(singleapplication) 3 | -------------------------------------------------------------------------------- /src/appicon/256-apps-nanonote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateau/nanonote/HEAD/src/appicon/256-apps-nanonote.png -------------------------------------------------------------------------------- /.changes/1.3.92.md: -------------------------------------------------------------------------------- 1 | ## 1.3.92 - 2023-04-02 2 | 3 | ### Added 4 | 5 | - Nanonote now highlights Markdown-like headings. 6 | -------------------------------------------------------------------------------- /src/appicon/1024-apps-nanonote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateau/nanonote/HEAD/src/appicon/1024-apps-nanonote.png -------------------------------------------------------------------------------- /src/app.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | appicon/sc-apps-nanonote.svg 4 | 5 | 6 | -------------------------------------------------------------------------------- /ci/apply-codingstyle: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | cd "$(git rev-parse --show-toplevel)" 5 | ci/check-codingstyle | patch -p0 6 | -------------------------------------------------------------------------------- /ci/README.md: -------------------------------------------------------------------------------- 1 | This directory contains various tools to manage the code. 2 | 3 | `run-clang-format.py` comes from . 4 | -------------------------------------------------------------------------------- /.changes/1.3.93.md: -------------------------------------------------------------------------------- 1 | ## 1.3.93 - 2023-04-03 2 | 3 | ### Fixed 4 | 5 | - Fixed a typo in the Appstream ID, which made creating a Flatpak for the app complicated. 6 | -------------------------------------------------------------------------------- /src/appicon/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | include(ECMInstallIcons) 2 | 3 | if (UNIX AND NOT APPLE) 4 | ecm_install_icons(ICONS sc-apps-${APP_NAME}.svg 5 | DESTINATION share/icons 6 | ) 7 | endif() 8 | -------------------------------------------------------------------------------- /ci/lib/macos-dependencies.sh: -------------------------------------------------------------------------------- 1 | main() { 2 | install_qt 3 | install_cmake 4 | install_ecm 5 | python -m venv .venv 6 | . .venv/bin/activate 7 | python -m pip install ds_store==1.1.2 8 | } 9 | -------------------------------------------------------------------------------- /.changes/1.4.1.md: -------------------------------------------------------------------------------- 1 | ## 1.4.1 - 2023-12-01 2 | 3 | ### Added 4 | 5 | - Nanonote now speaks Danish (Morgenkaff) 6 | - Nanonote now speaks Dutch (Heimen Stoffels) 7 | - Nanonote now speaks Polish (Marek Szumny) 8 | - Nanonote now speaks Norwegian (Vidar Karlsen) 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "singleapplication"] 2 | path = third-party/singleapplication/src 3 | url = https://github.com/itay-grudev/SingleApplication 4 | [submodule "third-party/Catch2"] 5 | path = third-party/Catch2 6 | url = https://github.com/catchorg/Catch2 7 | -------------------------------------------------------------------------------- /src/linux/nanonote.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Nanonote 3 | GenericName=Note taking application 4 | Comment=Minimalist note taking application 5 | Type=Application 6 | Exec=nanonote 7 | Icon=nanonote 8 | Terminal=false 9 | Categories=Qt;Utility;TextEditor; 10 | -------------------------------------------------------------------------------- /ci/headless-test-helper: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # A wrapper to ensure we have a window manager running when tests are run. 5 | # This is required for new windows to become focused when they are created. 6 | 7 | CMD=$* 8 | 9 | openbox & 10 | sleep 1 11 | $CMD 12 | -------------------------------------------------------------------------------- /tests/tests.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | 6 | int main(int argc, char* argv[]) { 7 | QApplication app(argc, argv); 8 | QTEST_SET_MAIN_SOURCE_PATH 9 | return Catch::Session().run(argc, argv); 10 | } 11 | -------------------------------------------------------------------------------- /ci/check-codingstyle: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | cd "$(dirname $0)/.." 5 | CLANG_FORMAT_CMD=${CLANG_FORMAT_CMD:-clang-format} 6 | $CLANG_FORMAT_CMD --version 7 | exec ci/run-clang-format.py \ 8 | --clang-format-executable $CLANG_FORMAT_CMD \ 9 | --recursive \ 10 | src tests 11 | -------------------------------------------------------------------------------- /tests/Catch2QtUtils.cpp: -------------------------------------------------------------------------------- 1 | #include "Catch2QtUtils.h" 2 | 3 | std::ostream& operator<<(std::ostream& ostr, const QString& str) { 4 | ostr << '"' << str.toStdString() << '"'; 5 | return ostr; 6 | } 7 | 8 | std::ostream& operator<<(std::ostream& ostr, const QUrl& url) { 9 | ostr << '"' << url.toEncoded().constData() << '"'; 10 | return ostr; 11 | } 12 | -------------------------------------------------------------------------------- /third-party/README.md: -------------------------------------------------------------------------------- 1 | This directory stores code coming from third-parties. They are integrated as git submodules. 2 | 3 | - Catch2: 4 | - singleapplication/src: . The submodule is in an `src` subdirectory so that we can define a real CMake target in `singleapplication/CMakeLists.txt`. 5 | -------------------------------------------------------------------------------- /src/translations.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | nanonote_da.qm 4 | nanonote_de.qm 5 | nanonote_en.qm 6 | nanonote_es.qm 7 | nanonote_fr.qm 8 | nanonote_nl.qm 9 | nanonote_no.qm 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Resources.h: -------------------------------------------------------------------------------- 1 | #ifndef RESOURCES_H 2 | #define RESOURCES_H 3 | 4 | class QString; 5 | 6 | #include 7 | 8 | namespace Resources { 9 | 10 | /** 11 | * Return the path to a directory called name in the resources directory if 12 | * it exists 13 | */ 14 | std::optional findDir(const QString& name); 15 | 16 | } // namespace Resources 17 | 18 | #endif /* RESOURCES_H */ 19 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | ul-indent: 3 | indent: 4 4 | 5 | line_length: false 6 | no-inline-html: false 7 | 8 | # Changelog files are full of duplicate headers 9 | no-duplicate-heading: false 10 | 11 | # .changes/*.md files start with h2 headers 12 | first-line-h1: false 13 | 14 | code-block-style: 15 | style: fenced 16 | fenced-code-language: false 17 | commands-show-output: false 18 | -------------------------------------------------------------------------------- /ci/install-clang-format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | pushd $PWD > /dev/null 5 | cd $(dirname $0) 6 | CI_DIR=$PWD 7 | popd > /dev/null 8 | 9 | CLANG_FORMAT_URL="https://github.com/muttleyxd/clang-tools-static-binaries/releases/download/master-1d7ec53d/clang-format-6_linux-amd64" 10 | CLANG_FORMAT_SHA256=624f90fd622102b6aa08affe055d8c18fdcafe013c7f01db18ffb55cd661bf04 11 | 12 | . $CI_DIR/lib/common.sh 13 | 14 | install_prebuilt_executable "$CLANG_FORMAT_URL" "$CLANG_FORMAT_SHA256" ./clang-format 15 | -------------------------------------------------------------------------------- /src/WheelZoomExtension.h: -------------------------------------------------------------------------------- 1 | #ifndef WHEELZOOMEXTENSION_H 2 | #define WHEELZOOMEXTENSION_H 3 | 4 | #include "TextEdit.h" 5 | 6 | class WheelZoomExtension : public TextEditExtension { 7 | Q_OBJECT 8 | public: 9 | explicit WheelZoomExtension(TextEdit* textEdit); 10 | 11 | bool wheel(QWheelEvent* event) override; 12 | 13 | signals: 14 | void adjustFontSize(int delta); 15 | 16 | private: 17 | int mPartialDelta = 0; 18 | qint64 mLastUpdate = 0; 19 | }; 20 | 21 | #endif // WHEELZOOMEXTENSION_H 22 | -------------------------------------------------------------------------------- /src/BuildConfig.h.in: -------------------------------------------------------------------------------- 1 | #ifndef CONFIG_H_IN 2 | #define CONFIG_H_IN 3 | 4 | #define ORGANIZATION_NAME "@ORGANIZATION_NAME@" 5 | 6 | #define APP_HUMAN_NAME "@APP_HUMAN_NAME@" 7 | #define APP_NAME "@APP_NAME@" 8 | #define APP_URL "@PROJECT_HOMEPAGE_URL@" 9 | #define APP_VERSION "@PROJECT_VERSION@" 10 | 11 | #define AUTHOR_NAME "@AUTHOR_NAME@" 12 | #define AUTHOR_EMAIL "@AUTHOR_EMAIL@" 13 | 14 | #define BIN_TO_DATA_DIR "@BIN_TO_DATA_DIR@" 15 | 16 | #define INSTALL_PREFIX "@CMAKE_INSTALL_PREFIX@" 17 | 18 | #define DATA_INSTALL_DIR "@DATA_INSTALL_DIR@" 19 | 20 | #endif // CONFIG_H_IN 21 | -------------------------------------------------------------------------------- /.changie.yaml: -------------------------------------------------------------------------------- 1 | changesDir: .changes 2 | unreleasedDir: unreleased 3 | headerPath: header.tpl.md 4 | versionHeaderPath: "" 5 | changelogPath: CHANGELOG.md 6 | versionExt: md 7 | versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' 8 | kindFormat: '### {{.Kind}}' 9 | changeFormat: '- {{.Body}}' 10 | kinds: 11 | - label: Added 12 | - label: Changed 13 | - label: Deprecated 14 | - label: Removed 15 | - label: Fixed 16 | newlines: 17 | afterChangelogHeader: 1 18 | beforeChangelogVersion: 1 19 | endOfVersion: 1 20 | beforeKind: 1 21 | afterKind: 1 22 | afterChange: 1 23 | -------------------------------------------------------------------------------- /src/SettingsDialog.h: -------------------------------------------------------------------------------- 1 | #ifndef SETTINGSDIALOG_H 2 | #define SETTINGSDIALOG_H 3 | 4 | #include 5 | 6 | class Settings; 7 | 8 | namespace Ui { 9 | class SettingsDialog; 10 | } 11 | 12 | class SettingsDialog : public QDialog { 13 | Q_OBJECT 14 | 15 | public: 16 | explicit SettingsDialog(Settings* settings, QWidget* parent = nullptr); 17 | ~SettingsDialog(); 18 | 19 | private: 20 | void setupConfigTab(); 21 | void setupAboutTab(); 22 | void updateFontFromSettings(); 23 | 24 | Ui::SettingsDialog* ui; 25 | Settings* mSettings; 26 | }; 27 | 28 | #endif // SETTINGSDIALOG_H 29 | -------------------------------------------------------------------------------- /.changes/1.4.0.md: -------------------------------------------------------------------------------- 1 | ## 1.4.0 - 2023-04-11 2 | 3 | ### Added 4 | 5 | - Add support for Markdown-style tasks in lists (Daniel Laidig) 6 | - Add tips page (Aurelien Gateau) 7 | - Nanonote now highlights Markdown-like headings (Aurelien Gateau) 8 | - Nanonote now speaks Czech (Amerey) 9 | 10 | ### Changed 11 | 12 | - Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig) 13 | 14 | ### Fixed 15 | 16 | - Make sure standard actions like Copy or Paste are translated (Aurelien Gateau) 17 | - Show keyboard shortcuts in context menus on macOS (Daniel Laidig) 18 | - Do not change cursor to pointing-hand when not over a link (Aurelien Gateau) 19 | -------------------------------------------------------------------------------- /src/TaskExtension.h: -------------------------------------------------------------------------------- 1 | #ifndef TASKEXTENSION_H 2 | #define TASKEXTENSION_H 3 | 4 | #include "TextEdit.h" 5 | 6 | #include 7 | 8 | #include 9 | 10 | class TaskExtension : public TextEditExtension { 11 | Q_OBJECT 12 | public: 13 | explicit TaskExtension(TextEdit* textEdit); 14 | 15 | void aboutToShowEditContextMenu(QMenu* menu, const QPoint& pos) override; 16 | 17 | bool mouseRelease(QMouseEvent* event) override; 18 | 19 | void insertOrToggleTask(); 20 | 21 | private: 22 | void toggleTaskUnderCursor(); 23 | 24 | const std::unique_ptr mTaskAction; 25 | }; 26 | 27 | #endif // TASKEXTENSION_H 28 | -------------------------------------------------------------------------------- /tests/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_custom_target(check 2 | COMMAND ${CMAKE_CTEST_COMMAND} --verbose --build-config "$" 3 | ) 4 | 5 | add_executable(tests 6 | tests.cpp 7 | IndentExtensionTest.cpp 8 | SyntaxHighlighterTest.cpp 9 | MoveLinesExtensionTest.cpp 10 | SearchWidgetTest.cpp 11 | Catch2QtUtils.cpp 12 | TaskExtensionTest.cpp 13 | TextUtils.cpp 14 | ) 15 | 16 | target_link_libraries(tests 17 | ${APPLIB_NAME} 18 | Qt5::Test 19 | Catch2::Catch2 20 | ) 21 | 22 | add_test(NAME tests COMMAND tests) 23 | 24 | # Before running tests, make sure tests they are built if necessary 25 | add_dependencies(check tests) 26 | -------------------------------------------------------------------------------- /third-party/singleapplication/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | project(singleapplication) 2 | 3 | find_package(Qt5 CONFIG REQUIRED Core Widgets Network) 4 | 5 | set(CMAKE_AUTOMOC ON) 6 | 7 | set(singleapplication_SRCS 8 | src/singleapplication.cpp 9 | src/singleapplication_p.cpp 10 | ) 11 | 12 | add_library(singleapplication STATIC ${singleapplication_SRCS}) 13 | 14 | target_compile_definitions(singleapplication 15 | PRIVATE -DQAPPLICATION_CLASS=QApplication 16 | INTERFACE -DQAPPLICATION_CLASS=QApplication) 17 | 18 | target_include_directories(singleapplication 19 | INTERFACE ${singleapplication_SOURCE_DIR}/src) 20 | 21 | target_link_libraries(singleapplication Qt5::Core Qt5::Widgets Qt5::Network) 22 | -------------------------------------------------------------------------------- /src/MoveLinesExtension.h: -------------------------------------------------------------------------------- 1 | #ifndef MOVELINEEXTENSION_H 2 | #define MOVELINEEXTENSION_H 3 | 4 | #include "TextEdit.h" 5 | 6 | #include 7 | 8 | class Action; 9 | 10 | class MoveLinesExtension : public TextEditExtension { 11 | Q_OBJECT 12 | public: 13 | explicit MoveLinesExtension(TextEdit* textEdit); 14 | ~MoveLinesExtension(); 15 | 16 | void aboutToShowEditContextMenu(QMenu* menu, const QPoint& pos) override; 17 | 18 | void moveUp(); 19 | void moveDown(); 20 | 21 | private: 22 | void moveSelectedLines(int delta); 23 | 24 | const std::unique_ptr mMoveUpAction; 25 | const std::unique_ptr mMoveDownAction; 26 | }; 27 | 28 | #endif // MOVELINEEXTENSION_H 29 | -------------------------------------------------------------------------------- /src/SyntaxHighlighter.h: -------------------------------------------------------------------------------- 1 | #ifndef LINKSYNTAXHIGHLIGHTER_H 2 | #define LINKSYNTAXHIGHLIGHTER_H 3 | 4 | #include 5 | #include 6 | 7 | class SyntaxHighlighter : public QSyntaxHighlighter { 8 | public: 9 | SyntaxHighlighter(QTextDocument* document); 10 | 11 | static QUrl getLinkAt(const QString& text, int position); 12 | static int getTaskCheckmarkPosAt(const QString& text, int position); 13 | 14 | protected: 15 | void highlightBlock(const QString& text) override; 16 | 17 | private: 18 | const QRegularExpression mLinkRegex; 19 | const QRegularExpression mTaskRegex; 20 | const QRegularExpression mHeadingRegex; 21 | }; 22 | 23 | #endif // LINKSYNTAXHIGHLIGHTER_H 24 | -------------------------------------------------------------------------------- /src/LinkExtension.h: -------------------------------------------------------------------------------- 1 | #ifndef LINKEXTENSION_H 2 | #define LINKEXTENSION_H 3 | 4 | #include "TextEdit.h" 5 | 6 | #include 7 | 8 | class LinkExtension : public TextEditExtension { 9 | Q_OBJECT 10 | public: 11 | explicit LinkExtension(TextEdit* textEdit); 12 | 13 | void aboutToShowContextMenu(QMenu* menu, const QPoint& pos) override; 14 | 15 | bool keyPress(QKeyEvent* event) override; 16 | 17 | bool keyRelease(QKeyEvent* event) override; 18 | 19 | bool mouseMove(QMouseEvent* event) override; 20 | 21 | bool mouseRelease(QMouseEvent* event) override; 22 | 23 | private: 24 | void updateMouseCursor(); 25 | void openLinkUnderCursor(); 26 | void reset(); 27 | 28 | const std::unique_ptr mOpenLinkAction; 29 | }; 30 | 31 | #endif // LINKEXTENSION_H 32 | -------------------------------------------------------------------------------- /.github/workflows/changelog-check.yml: -------------------------------------------------------------------------------- 1 | name: Require change fragment 2 | 3 | on: 4 | pull_request: 5 | types: 6 | # On by default if you specify no types. 7 | - "opened" 8 | - "reopened" 9 | - "synchronize" 10 | # For `skip-changelog` only. 11 | - "labeled" 12 | - "unlabeled" 13 | 14 | jobs: 15 | check-changelog: 16 | runs-on: ubuntu-20.04 17 | steps: 18 | - name: "Check for changelog entry" 19 | uses: brettcannon/check-for-changed-files@v1.1.0 20 | with: 21 | file-pattern: | 22 | .changes/unreleased/*.yaml 23 | CHANGELOG.md 24 | skip-label: "skip-changelog" 25 | failure-message: "Missing a changelog file in ${file-pattern}; please add one or apply the ${skip-label} label to the pull request" 26 | -------------------------------------------------------------------------------- /cmake/find_python_module.cmake: -------------------------------------------------------------------------------- 1 | find_package(Python3) 2 | 3 | # Use pip to check if a Python module is installed. 4 | # 5 | # ${module}_FOUND is set to ON if the module is found and OFF if not. 6 | function(find_python_module module) 7 | if (NOT Python3_FOUND) 8 | message(STATUS "Could not find Python 3 module ${module}: no Python 3 interpreter") 9 | set(${module}_FOUND OFF PARENT_SCOPE) 10 | return() 11 | endif() 12 | execute_process( 13 | COMMAND ${Python3_EXECUTABLE} -m pip show -q ${module} 14 | RESULT_VARIABLE result 15 | ) 16 | if (result EQUAL 0) 17 | message(STATUS "Found Python 3 module ${module}") 18 | set(${module}_FOUND ON PARENT_SCOPE) 19 | else() 20 | message(STATUS "Could not find Python 3 module ${module}") 21 | set(${module}_FOUND OFF PARENT_SCOPE) 22 | endif() 23 | endfunction() 24 | -------------------------------------------------------------------------------- /tests/TextUtils.h: -------------------------------------------------------------------------------- 1 | #ifndef TEXTUTILS_H 2 | #define TEXTUTILS_H 3 | 4 | class TextEdit; 5 | 6 | class QString; 7 | 8 | /** 9 | * Takes a TextEdit and a string and setup the TextEdit content, selection and cursor position. 10 | * 11 | * The text parameter contains the text to use, and some special characters: 12 | * 13 | * - It *must* contain a '|' to indicate the cursor position. 14 | * - It *can* contain a '*' to indicate the selection start. 15 | * 16 | * '|' can appear before '*' in the case of an upward selection. 17 | */ 18 | void setupTextEditContent(TextEdit* edit, const QString& text); 19 | 20 | /** 21 | * Dumps the TextEdit content in the format described in setupTextEditContent() doc. It makes it 22 | * easy to write tests to verify the state of the TextEdit matches expectations. 23 | */ 24 | QString dumpTextEditContent(TextEdit* edit); 25 | 26 | #endif // TEXTUTILS_H 27 | -------------------------------------------------------------------------------- /ci/lib/linux-dependencies.sh: -------------------------------------------------------------------------------- 1 | main() { 2 | echo_title "Installing Linux packages" 3 | if has_command apt-get ; then 4 | $RUN_AS_ROOT apt-get update 5 | # file is needed by dpkg to generate shlib dependencies 6 | # xvfb and openbox are needed to run UI tests in headless environments 7 | $RUN_AS_ROOT apt-get install -y --no-install-recommends \ 8 | dpkg-dev \ 9 | extra-cmake-modules \ 10 | file \ 11 | g++ \ 12 | git \ 13 | make \ 14 | openbox \ 15 | python3 \ 16 | python3-pip \ 17 | python3-setuptools \ 18 | qtbase5-dev \ 19 | qttools5-dev \ 20 | rpm \ 21 | xvfb 22 | else 23 | die "Sorry, I don't know how to install the required packages on your distribution." 24 | fi 25 | 26 | install_cmake 27 | } 28 | -------------------------------------------------------------------------------- /src/Settings.h: -------------------------------------------------------------------------------- 1 | #ifndef SETTINGS_H 2 | #define SETTINGS_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | class Settings : public QObject { 9 | Q_OBJECT 10 | public: 11 | explicit Settings(QObject* parent = nullptr); 12 | static QString notePath(); 13 | void load(); 14 | void save(); 15 | QFont defaultFont() const; 16 | 17 | // User-modifiable settings 18 | bool alwaysOnTop() const; 19 | void setAlwaysOnTop(bool value); 20 | 21 | QFont font() const; 22 | void setFont(const QFont& value); 23 | 24 | QRect geometry() const; 25 | void setGeometry(const QRect& value); 26 | 27 | signals: 28 | void alwaysOnTopChanged(bool alwaysOnTop); 29 | void fontChanged(const QFont& font); 30 | void geometryChanged(const QRect& geometry); 31 | 32 | private: 33 | bool mAlwaysOnTop = false; 34 | QFont mFont; 35 | QRect mGeometry; 36 | }; 37 | 38 | #endif // SETTINGS_H 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v6.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | # Markdown 13 | - repo: https://github.com/igorshubovych/markdownlint-cli 14 | rev: v0.41.0 15 | hooks: 16 | - id: markdownlint-fix 17 | args: [] 18 | 19 | # Python 20 | - repo: https://github.com/psf/black 21 | rev: 25.11.0 22 | hooks: 23 | - id: black 24 | - repo: https://github.com/PyCQA/isort 25 | rev: 7.0.0 26 | hooks: 27 | - id: isort 28 | args: [--profile, black] 29 | 30 | # Check GitHub workflows 31 | - repo: https://github.com/python-jsonschema/check-jsonschema 32 | rev: 0.35.0 33 | hooks: 34 | - id: check-github-workflows 35 | -------------------------------------------------------------------------------- /src/WheelZoomExtension.cpp: -------------------------------------------------------------------------------- 1 | #include "WheelZoomExtension.h" 2 | 3 | #include 4 | 5 | static constexpr int SCROLL_TIMEOUT = 1000; // in milliseconds 6 | 7 | WheelZoomExtension::WheelZoomExtension(TextEdit* textEdit) : TextEditExtension(textEdit) { 8 | } 9 | 10 | bool WheelZoomExtension::wheel(QWheelEvent* event) { 11 | if (event->modifiers() != Qt::CTRL) { 12 | return false; 13 | } 14 | 15 | int delta = event->angleDelta().y(); 16 | if (delta == 0) { 17 | return false; 18 | } 19 | 20 | qint64 time = QDateTime::currentMSecsSinceEpoch(); 21 | if (time - mLastUpdate > SCROLL_TIMEOUT) { 22 | mPartialDelta = 0; 23 | } 24 | 25 | mPartialDelta += delta; 26 | int steps = mPartialDelta / QWheelEvent::DefaultDeltasPerStep; 27 | if (steps != 0) { 28 | emit adjustFontSize(steps); 29 | mPartialDelta -= steps * QWheelEvent::DefaultDeltasPerStep; 30 | } 31 | mLastUpdate = time; 32 | return true; 33 | } 34 | -------------------------------------------------------------------------------- /src/IndentExtension.h: -------------------------------------------------------------------------------- 1 | #ifndef INDENTEXTENSION_H 2 | #define INDENTEXTENSION_H 3 | 4 | #include "TextEdit.h" 5 | 6 | #include 7 | 8 | class IndentExtension : public TextEditExtension { 9 | Q_OBJECT 10 | public: 11 | explicit IndentExtension(TextEdit* textEdit); 12 | 13 | void aboutToShowEditContextMenu(QMenu* menu, const QPoint& pos) override; 14 | bool keyPress(QKeyEvent* event) override; 15 | 16 | private: 17 | using ProcessSelectionCallback = std::function; 18 | bool canRemoveIndentation() const; 19 | bool isAtStartOfListLine() const; 20 | bool isAtEndOfLine() const; 21 | bool isIndentedLine() const; 22 | void insertIndentation(); 23 | void removeIndentation(); 24 | void insertIndentedLine(); 25 | void processSelection(ProcessSelectionCallback callback); 26 | void onTabPressed(); 27 | void onEnterPressed(); 28 | 29 | QAction* mIndentAction; 30 | QAction* mUnindentAction; 31 | }; 32 | 33 | #endif // INDENTEXTENSION_H 34 | -------------------------------------------------------------------------------- /tests/Catch2QtUtils.h: -------------------------------------------------------------------------------- 1 | #ifndef CATCH2QTUTILS_H 2 | #define CATCH2QTUTILS_H 3 | 4 | // Qt 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | // std 12 | #include 13 | 14 | /** 15 | * Wait until the active window can be qobject_cast'ed to class T. 16 | * @return a pointer to the active window on success, nullptr on failure 17 | */ 18 | template static T waitForActiveWindow(int timeout = 5000) { 19 | QElapsedTimer timer; 20 | timer.start(); 21 | while (!timer.hasExpired(timeout)) { 22 | T window = qobject_cast(QApplication::activeWindow()); 23 | if (window) { 24 | return window; 25 | } 26 | QTest::qWait(200); 27 | } 28 | return nullptr; 29 | } 30 | 31 | // Let Catch know how to print some Qt types 32 | std::ostream& operator<<(std::ostream& ostr, const QString& str); 33 | std::ostream& operator<<(std::ostream& ostr, const QUrl& url); 34 | 35 | #endif // CATCH2QTUTILS_H 36 | -------------------------------------------------------------------------------- /docs/translations.md: -------------------------------------------------------------------------------- 1 | # Translations 2 | 3 | Translations are stored in .ts files in the [src/translations/](../src/translations/) directory. 4 | 5 | ## Adding a new language 6 | 7 | - Add a .ts file to the `TS_FILES` variable in [src/CMakeLists.txt](../src/CMakeLists.txt) 8 | - Run `make lupdate` in the build directory to create the .ts file 9 | - Translate the content of the .ts file with Linguist 10 | 11 | ## Testing a translation 12 | 13 | - Build and install the app, preferably in a directory where you don't need to be root to install. This example assumes you install it to `$HOME/tmp/nanonote-inst`: 14 | 15 | ``` 16 | mkdir build 17 | cd build 18 | cmake -DCMAKE_INSTALL_PREFIX=$HOME/tmp/nanonote-inst 19 | make 20 | make install 21 | ``` 22 | 23 | - Run the app, possibly with the `LANGUAGE` variable set to force the language: 24 | 25 | ``` 26 | LANGUAGE= $HOME/tmp/nanonote-inst/bin/nanonote 27 | ``` 28 | 29 | - If something is wrong: fix the translation, run `make install` and try again 30 | -------------------------------------------------------------------------------- /ci/lib/windows-dependencies.sh: -------------------------------------------------------------------------------- 1 | ICOUTILS_VERSION=0.32.3 2 | ICOUTILS_URL="https://downloads.sourceforge.net/project/unix-utils/icoutils/icoutils-$ICOUTILS_VERSION-x86_64.zip" 3 | ICOUTILS_SHA256=1773b553fed5565004606d8732d9980ecec82cdccf35103700d71a17b4059723 4 | 5 | install_icoutils() { 6 | echo_title "Installing icoutils" 7 | install_prebuilt_archive $ICOUTILS_URL $ICOUTILS_SHA256 $INST_DIR/icoutils.zip $INST_DIR 8 | local icotool_exe=$INST_DIR/icoutils-$ICOUTILS_VERSION-x86_64/bin/icotool.exe 9 | if [ ! -x "$icotool_exe" ] ; then 10 | die "Can't find icotool.exe: $icotool_exe" 11 | fi 12 | prepend_path $(dirname $icotool_exe) 13 | } 14 | 15 | install_qtmingw() { 16 | echo_title "Installing Qt mingw" 17 | local qt_install_dir=$INST_DIR/qt 18 | aqt install-tool \ 19 | windows desktop $QT_MINGW \ 20 | --outputdir $qt_install_dir 21 | 22 | prepend_path $qt_install_dir/Tools/$QT_MINGW_PATH 23 | } 24 | 25 | main() { 26 | install_icoutils 27 | install_qt 28 | install_qtmingw 29 | install_cmake 30 | install_ecm 31 | } 32 | -------------------------------------------------------------------------------- /src/macos/Info.plist.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrincipalClass 6 | NSApplication 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleDisplayName 10 | ${APP_HUMAN_NAME} 11 | CFBundleExecutable 12 | ${APP_NAME} 13 | CFBundleIconFile 14 | ${MACOSX_BUNDLE_ICON_FILE} 15 | CFBundleIdentifier 16 | ${INVERSE_ORGANIZATION_NAME}.${APP_NAME} 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | ${PROJECT_VERSION} 23 | CFBundleVersion 24 | ${PROJECT_VERSION} 25 | NSHumanReadableCopyright 26 | Copyright ${AUTHOR_NAME} 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: tag 2 | 3 | permissions: 4 | # Required by `gh` to be able to create the release 5 | contents: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - '[0-9]*' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | uses: ./.github/workflows/main.yml 16 | 17 | release: 18 | runs-on: ubuntu-latest 19 | needs: build 20 | steps: 21 | # `gh` needs a checkout of the repository to work 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Read info 26 | id: info 27 | shell: bash 28 | run: | 29 | echo "tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT 30 | 31 | - name: Download artifacts 32 | uses: actions/download-artifact@v4 33 | with: 34 | path: artifacts 35 | pattern: artifacts-* 36 | merge-multiple: true 37 | 38 | - name: Create release 39 | run: | 40 | gh release create --draft --verify-tag "$TAG" --title "$TAG" artifacts/nanonote*.* 41 | env: 42 | TAG: ${{ steps.info.outputs.tag }} 43 | GH_TOKEN: ${{ github.token }} 44 | -------------------------------------------------------------------------------- /docs/tips.md: -------------------------------------------------------------------------------- 1 | # Tips and tricks 2 | 3 | ## Moving selected lines up or down 4 | 5 | To move the selected lines up or down, use `Alt+Shift+Up` or `Alt+Shift+Down`. 6 | 7 | ## Indenting/unindenting 8 | 9 | Select multiple lines, then use use `Tab` or `Shift+Tab` to indent or unindent them. 10 | 11 | When the cursor is in the middle of a line, use `Ctrl+I` or `Ctrl+U` to indent or unindent the line. 12 | 13 | ## Links 14 | 15 | Nanonote recognizes URLs. To open an URL with the mouse, hold `Ctrl` and click on it. To open an URL with the keyboard, move the cursor to it and use `Ctrl+G`. 16 | 17 | ## List entries 18 | 19 | If you start a line using `-` or `*`, Nanonote automatically adds new list entries when you press Enter. 20 | 21 | ## Checkable list entries 22 | 23 | You can create checkable list entries with `- [ ]` or `* [ ]`, like this: 24 | 25 | ``` 26 | - [x] Buy dog food 27 | - [ ] Feed the dog 28 | ``` 29 | 30 | Checkboxes can be toggled with `Ctrl+Enter` or by holding `Ctrl` and clicking on them. 31 | 32 | ## Headings 33 | 34 | Nanonote highlights Markdown-like headings (headings starting with `#`), rendering them in bold. This is helpful to segment your notes. 35 | -------------------------------------------------------------------------------- /src/SearchWidget.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | 11 | class TextEdit; 12 | 13 | namespace Ui { 14 | class SearchWidget; 15 | } 16 | 17 | class SearchWidget : public QWidget { 18 | Q_OBJECT 19 | 20 | public: 21 | explicit SearchWidget(TextEdit* textEdit, QWidget* parent = nullptr); 22 | ~SearchWidget(); 23 | 24 | void initialize(const QString& text); 25 | void uninitialize(); 26 | 27 | signals: 28 | void closeClicked(); 29 | 30 | private: 31 | void selectNextMatch(); 32 | void selectPreviousMatch(); 33 | void onDocumentChanged(); 34 | void selectCurrentMatch(); 35 | void updateCountLabel(); 36 | void highlightMatches(); 37 | void removeHighlights(); 38 | void onLineEditChanged(); 39 | void search(); 40 | void updateMatchPositions(); 41 | void updateLineEdit(); 42 | 43 | const std::unique_ptr mUi; 44 | TextEdit* const mTextEdit; 45 | 46 | std::vector mMatchPositions; 47 | // The content of the TextEdit last time we did a search 48 | QString mPreviousText; 49 | std::optional mCurrentMatch; 50 | }; 51 | -------------------------------------------------------------------------------- /ci/build-app: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | pushd $PWD > /dev/null 5 | cd $(dirname $0)/.. 6 | SRC_DIR=$PWD 7 | popd > /dev/null 8 | 9 | . $SRC_DIR/ci/lib/common.sh 10 | 11 | BUILD_DIR=$SRC_DIR/build 12 | INSTALL_DIR=$SRC_DIR/inst 13 | 14 | BUILD_TYPE=Release 15 | 16 | BUILD_CMD="cmake --build . --config $BUILD_TYPE" 17 | 18 | echo_title Configuring 19 | mkdir $BUILD_DIR 20 | cd $BUILD_DIR 21 | cmake \ 22 | -DCMAKE_BUILD_TYPE=$BUILD_TYPE \ 23 | -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR \ 24 | -G "$CMAKE_GENERATOR" \ 25 | $SRC_DIR 26 | 27 | echo_title Updating translations 28 | $BUILD_CMD --target lupdate 29 | 30 | echo_title Building 31 | $BUILD_CMD --parallel $NPROC 32 | 33 | echo_title Running tests 34 | test_cmd="$BUILD_CMD --target check" 35 | if is_linux && [ -z "${DISPLAY:-}" ] ; then 36 | xvfb-run $SRC_DIR/ci/headless-test-helper $test_cmd 37 | else 38 | $test_cmd 39 | fi 40 | 41 | echo_title Installing 42 | $BUILD_CMD --target install 43 | 44 | echo_title "Creating binary packages" 45 | $BUILD_CMD --target package 46 | 47 | if is_linux ; then 48 | # No need to build the source package everywhere, arbitrarily decide to 49 | # build it on Linux 50 | echo_title "Creating source package" 51 | $BUILD_CMD --target package_source 52 | fi 53 | -------------------------------------------------------------------------------- /docs/release-check-list.md: -------------------------------------------------------------------------------- 1 | # Release check list 2 | 3 | ## Pre-release 4 | 5 | - [ ] Check working tree is up to date and clean: 6 | 7 | inv create-release-branch 8 | 9 | - [ ] Update .ts files: 10 | 11 | inv update-ts 12 | 13 | - [ ] Commit and push 14 | 15 | inv commit-push 16 | 17 | - [ ] Tag pre-release 18 | 19 | inv tag 20 | 21 | - [ ] Wait for the CI to build, then smoke test artifacts 22 | 23 | - [ ] Publish artifacts 24 | 25 | inv publish --pre 26 | 27 | - [ ] Ask translators to update their translations 28 | 29 | - [ ] Report any changes to release-check-list 30 | 31 | ## Release 32 | 33 | - [ ] Check working tree is up to date and clean: 34 | 35 | inv create-release-branch 36 | 37 | - [ ] Commit and push 38 | 39 | inv commit-push 40 | 41 | - [ ] Smoke test binary packages generated by CI 42 | 43 | - Test welcome text is OK 44 | - Test screenshot matches 45 | - Test translations are complete 46 | 47 | - [ ] Tag release 48 | 49 | inv tag 50 | 51 | - [ ] Wait for the CI to build, then smoke test artifacts 52 | 53 | - [ ] Publish generated packages on GitHub 54 | 55 | inv publish 56 | 57 | - [ ] Update Flatpak package 58 | 59 | - [ ] Report any changes to release-check-list 60 | 61 | ## Spread 62 | 63 | - [ ] Write blog post 64 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | pull_request: 5 | # `workflow_call` is used by the tag workflow to trigger the `build` job 6 | workflow_call: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | check-codingstyle: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install dependencies 15 | run: ci/install-clang-format 16 | - name: check coding style 17 | run: CLANG_FORMAT_CMD=./clang-format ci/check-codingstyle 18 | 19 | build: 20 | runs-on: ${{ matrix.os }} 21 | env: 22 | PYTHONUNBUFFERED: 1 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: 27 | - ubuntu-22.04 28 | - macos-15-intel 29 | - windows-2022 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | submodules: true 34 | - name: Install dependencies 35 | run: ci/install-dependencies deps 36 | shell: bash 37 | - name: Env vars 38 | run: cat deps/env.sh 39 | shell: bash 40 | - name: Build 41 | run: . deps/env.sh && ci/build-app 42 | shell: bash 43 | - uses: actions/upload-artifact@v4 44 | with: 45 | name: artifacts-${{ matrix.os }} 46 | path: build/nanonote[-_]*.* 47 | -------------------------------------------------------------------------------- /tests/SyntaxHighlighterTest.cpp: -------------------------------------------------------------------------------- 1 | #include "SyntaxHighlighter.h" 2 | 3 | #include 4 | 5 | #include "Catch2QtUtils.h" 6 | 7 | TEST_CASE("getLinkAt") { 8 | QString uglyUrl = "http://foo.com/~arg;foo+bar%20#"; 9 | QString text = QString("link to %1. The end.").arg(uglyUrl); 10 | QUrl expected(uglyUrl); 11 | SECTION("before link") { 12 | REQUIRE(SyntaxHighlighter::getLinkAt(text, 0) == QUrl()); 13 | } 14 | SECTION("at link") { 15 | REQUIRE(SyntaxHighlighter::getLinkAt(text, 10) == expected); 16 | } 17 | SECTION("after link") { 18 | REQUIRE(SyntaxHighlighter::getLinkAt(text, text.length() - 4) == QUrl()); 19 | } 20 | } 21 | 22 | TEST_CASE("getTaskCheckmarkPosAt") { 23 | QString text = QString(" - [x] test task"); 24 | SECTION("before task") { 25 | REQUIRE(SyntaxHighlighter::getTaskCheckmarkPosAt(text, 5) == -1); 26 | } 27 | SECTION("start of task") { 28 | REQUIRE(SyntaxHighlighter::getTaskCheckmarkPosAt(text, 6) == 7); 29 | } 30 | SECTION("inside task") { 31 | REQUIRE(SyntaxHighlighter::getTaskCheckmarkPosAt(text, 8) == 7); 32 | } 33 | SECTION("end of task") { 34 | REQUIRE(SyntaxHighlighter::getTaskCheckmarkPosAt(text, 9) == -1); 35 | } 36 | SECTION("after task") { 37 | REQUIRE(SyntaxHighlighter::getTaskCheckmarkPosAt(text, 10) == -1); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ci/install-dependencies: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | pushd $PWD > /dev/null 5 | cd $(dirname $0) 6 | CI_DIR=$PWD 7 | popd > /dev/null 8 | 9 | SRC_DIR=$CI_DIR/.. 10 | 11 | . $CI_DIR/lib/common.sh 12 | 13 | QT_ARCH_WINDOWS=win64_mingw81 14 | QT_MINGW=tools_mingw81 15 | QT_MINGW_PATH=mingw810_64/bin 16 | QT_ARCH_MACOS=clang_64 17 | QT_VERSION=5.15.2 18 | 19 | ECM_VERSION=5.69.0 20 | 21 | CMAKE_VERSION=3.31.\* 22 | CMAKE_GENERATOR=Ninja 23 | 24 | QPROPGEN_VERSION=0.1.\* 25 | 26 | add_env_var() { 27 | echo "export $1=\"$2\"" >> $ENV_FILE 28 | export $1="$2" 29 | } 30 | 31 | prepend_path() { 32 | if [ ! -d "$1" ] ; then 33 | die "prepend_path: \"$1\" does not exist" 34 | fi 35 | echo "export PATH=$1:\$PATH" >> $ENV_FILE 36 | export PATH=$1:$PATH 37 | } 38 | 39 | if is_windows ; then 40 | CMAKE_GENERATOR="MinGW Makefiles" 41 | fi 42 | 43 | . $CI_DIR/lib/common-dependencies.sh 44 | . $CI_DIR/lib/$OS-dependencies.sh 45 | 46 | INST_DIR=$1 47 | mkdir -p $INST_DIR 48 | 49 | # Make INST_DIR absolute 50 | pushd $PWD > /dev/null 51 | cd $INST_DIR 52 | INST_DIR=$PWD 53 | popd 54 | 55 | if is_windows ; then 56 | INST_DIR=$(cygpath $INST_DIR) 57 | fi 58 | 59 | ENV_FILE=$INST_DIR/env.sh 60 | 61 | rm -f $ENV_FILE 62 | touch $ENV_FILE 63 | 64 | add_env_var CMAKE_INSTALL_PREFIX $INST_DIR 65 | add_env_var CMAKE_PREFIX_PATH $INST_DIR 66 | add_env_var CMAKE_GENERATOR "$CMAKE_GENERATOR" 67 | 68 | main 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/agateau/nanonote/actions/workflows/main.yml/badge.svg)](https://github.com/agateau/nanonote/actions/workflows/main.yml) 2 | 3 | # Nanonote 4 | 5 | Nanonote is a minimalist note taking application. 6 | 7 | ![Screenshot](screenshot.png) 8 | 9 | It automatically saves anything you type. Being minimalist means it has no synchronisation, does not support multiple documents, images or any advanced formatting (the only formatting is highlighting URLs and Markdown-like headings). 10 | 11 | It is developed and tested on Linux but also works on macOS and Windows as well. 12 | 13 | ## Packages 14 | 15 | Binary packages for Linux, macOS and Windows are available on the [release page][]. 16 | 17 | [release page]: https://github.com/agateau/nanonote/releases 18 | 19 | ## Tips and tricks 20 | 21 | Even if Nanonote has a minimalist user interface, it comes with some handy shortcuts. Learn more about them from the [tips and tricks page](docs/tips.md). 22 | 23 | ## Building it 24 | 25 | Nanonote requires Qt 5 and CMake. To build it, do the following: 26 | 27 | Get the source: 28 | 29 | ``` 30 | git clone https://github.com/agateau/nanonote 31 | cd nanonote 32 | git submodule update --init 33 | ``` 34 | 35 | Build Nanonote: 36 | 37 | ``` 38 | mkdir build 39 | cd build 40 | cmake .. 41 | make 42 | sudo make install 43 | ``` 44 | 45 | You can also build rpm and deb files using `make package` after `make`. 46 | -------------------------------------------------------------------------------- /src/Info.plist.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrincipalClass 6 | NSApplication 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | ${MACOSX_BUNDLE_EXECUTABLE_NAME} 11 | CFBundleGetInfoString 12 | ${MACOSX_BUNDLE_INFO_STRING} 13 | CFBundleIconFile 14 | ${MACOSX_BUNDLE_ICON_FILE} 15 | CFBundleIdentifier 16 | ${MACOSX_BUNDLE_GUI_IDENTIFIER} 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleLongVersionString 20 | ${MACOSX_BUNDLE_LONG_VERSION_STRING} 21 | CFBundleName 22 | ${MACOSX_BUNDLE_BUNDLE_NAME} 23 | CFBundlePackageType 24 | APPL 25 | CFBundleShortVersionString 26 | ${MACOSX_BUNDLE_SHORT_VERSION_STRING} 27 | CFBundleVersion 28 | ${MACOSX_BUNDLE_BUNDLE_VERSION} 29 | CSResourcesFileMapped 30 | 31 | NSHumanReadableCopyright 32 | ${MACOSX_BUNDLE_COPYRIGHT} 33 | 34 | 35 | -------------------------------------------------------------------------------- /cmake/translations.cmake: -------------------------------------------------------------------------------- 1 | include(CMakeParseArguments) 2 | 3 | # Add an "lupdate" target. Use this target to update the .ts files from the 4 | # latest source files. 5 | # Syntax: add_lupdate_target(SOURCES TS_FILES OPTIONS ) 6 | function(add_lupdate_target) 7 | set(options) 8 | set(one_value_args) 9 | set(multi_value_args SOURCES TS_FILES OPTIONS) 10 | 11 | cmake_parse_arguments(lupdate "${options}" "${one_value_args}" "${multi_value_args}" ${ARGN}) 12 | 13 | # List all sources in a file and call lupdate with this file as argument 14 | # instead of adding all sources on the command line, to ensure the command 15 | # line does not get too long 16 | set(content) 17 | foreach(src_file ${lupdate_SOURCES}) 18 | set(content "${src_file}\n${content}") 19 | endforeach() 20 | 21 | # Add include directories 22 | get_directory_property(include_dirs INCLUDE_DIRECTORIES) 23 | foreach(include_dir ${include_dirs}) 24 | get_filename_component(include_dir "${include_dir}" ABSOLUTE) 25 | set(content "-I${include_dir}\n${content}") 26 | endforeach() 27 | 28 | set(list_file "${CMAKE_CURRENT_BINARY_DIR}${CMAKE_FILES_DIRECTORY}/sources.lst") 29 | file(WRITE ${list_file} "${content}") 30 | 31 | # Add the lupdate target 32 | add_custom_target(lupdate 33 | COMMAND Qt5::lupdate ${lupdate_options} "@${list_file}" -ts ${lupdate_TS_FILES} 34 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} 35 | ) 36 | endfunction() 37 | -------------------------------------------------------------------------------- /src/Resources.cpp: -------------------------------------------------------------------------------- 1 | #include "Resources.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "BuildConfig.h" 9 | 10 | class ResourcesInfo { 11 | public: 12 | ResourcesInfo() { 13 | if (tryPath(QCoreApplication::applicationDirPath() + '/' + BIN_TO_DATA_DIR)) { 14 | return; 15 | } 16 | // Try the absolute install path, useful when running the app from the 17 | // build directory 18 | if (tryPath(QString(INSTALL_PREFIX) + '/' + DATA_INSTALL_DIR)) { 19 | return; 20 | } 21 | qWarning() << "Can't find data dir, has the app been installed?"; 22 | } 23 | 24 | std::optional findDir(const QString& name) const { 25 | if (!mDataDir.has_value()) { 26 | return {}; 27 | } 28 | auto path = mDataDir.value().filePath(name); 29 | if (!QFile::exists(path)) { 30 | qWarning() << "Can't find" << name << "in" << mDataDir.value().absolutePath(); 31 | return {}; 32 | } 33 | return path; 34 | } 35 | 36 | private: 37 | bool tryPath(const QString& path) { 38 | QDir dir(path); 39 | if (!dir.exists()) { 40 | return false; 41 | } 42 | mDataDir = dir; 43 | return true; 44 | } 45 | 46 | std::optional mDataDir; 47 | }; 48 | 49 | Q_GLOBAL_STATIC(ResourcesInfo, info) 50 | 51 | namespace Resources { 52 | 53 | std::optional findDir(const QString& name) { 54 | return info()->findDir(name); 55 | } 56 | 57 | } // namespace Resources 58 | -------------------------------------------------------------------------------- /src/TextEdit.h: -------------------------------------------------------------------------------- 1 | #ifndef TEXTEDIT_H 2 | #define TEXTEDIT_H 3 | 4 | #include 5 | 6 | class TextEdit; 7 | 8 | /** 9 | * @brief Extension system for TextEdit 10 | * 11 | * event-like methods must return true if they processed the event and do not 12 | * want other extensions to receive it. 13 | */ 14 | class TextEditExtension : public QObject { 15 | public: 16 | explicit TextEditExtension(TextEdit* textEdit); 17 | 18 | virtual void aboutToShowContextMenu(QMenu* menu, const QPoint& pos); 19 | 20 | virtual void aboutToShowEditContextMenu(QMenu* menu, const QPoint& pos); 21 | 22 | virtual void aboutToShowViewContextMenu(QMenu* menu, const QPoint& pos); 23 | 24 | virtual bool keyPress(QKeyEvent* event); 25 | 26 | virtual bool keyRelease(QKeyEvent* event); 27 | 28 | virtual bool mouseRelease(QMouseEvent* event); 29 | 30 | virtual bool mouseMove(QMouseEvent* event); 31 | 32 | virtual bool wheel(QWheelEvent* event); 33 | 34 | protected: 35 | TextEdit* mTextEdit; 36 | }; 37 | 38 | class TextEdit : public QPlainTextEdit { 39 | Q_OBJECT 40 | public: 41 | TextEdit(QWidget* parent = nullptr); 42 | 43 | void addExtension(TextEditExtension* extension); 44 | 45 | protected: 46 | void contextMenuEvent(QContextMenuEvent* event) override; 47 | void keyPressEvent(QKeyEvent* event) override; 48 | void keyReleaseEvent(QKeyEvent* event) override; 49 | void mouseReleaseEvent(QMouseEvent* event) override; 50 | void mouseMoveEvent(QMouseEvent* event) override; 51 | void wheelEvent(QWheelEvent* event) override; 52 | 53 | private: 54 | QList mExtensions; 55 | }; 56 | 57 | #endif /* TEXTEDIT_H */ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2018 Aurélien Gâteau and contributors. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted (subject to the limitations in the 7 | disclaimer below) provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the 15 | distribution. 16 | 17 | * The name of the contributors may not be used to endorse or 18 | promote products derived from this software without specific prior 19 | written permission. 20 | 21 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE 22 | GRANTED BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT 23 | HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED 24 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 25 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 27 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 28 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 29 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 30 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 32 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 33 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | Standard: Cpp11 4 | BasedOnStyle: Google 5 | 6 | # Line breaks 7 | ColumnLimit: 100 8 | BreakBeforeBraces: Attach 9 | AlwaysBreakTemplateDeclarations: false 10 | AlwaysBreakBeforeMultilineStrings: false 11 | BreakBeforeBinaryOperators: NonAssignment 12 | BreakBeforeTernaryOperators: true 13 | PenaltyBreakBeforeFirstCallParameter: 19 14 | PenaltyBreakComment: 300 15 | PenaltyBreakString: 1000 16 | PenaltyBreakFirstLessLess: 120 17 | PenaltyExcessCharacter: 1000000 18 | PenaltyReturnTypeOnItsOwnLine: 60 19 | AllowShortBlocksOnASingleLine: false 20 | AllowShortCaseLabelsOnASingleLine: false 21 | AllowShortIfStatementsOnASingleLine: false 22 | AllowShortLoopsOnASingleLine: false 23 | AllowShortFunctionsOnASingleLine: None 24 | BinPackArguments: false 25 | BinPackParameters: false 26 | AllowAllParametersOfDeclarationOnNextLine: true 27 | BreakConstructorInitializers: BeforeComma 28 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 29 | MaxEmptyLinesToKeep: 1 30 | KeepEmptyLinesAtTheStartOfBlocks: false 31 | 32 | # Indentation 33 | UseTab: Never 34 | IndentWidth: 4 35 | TabWidth: 4 36 | AccessModifierOffset: -4 37 | ConstructorInitializerIndentWidth: 8 38 | IndentCaseLabels: false 39 | IndentFunctionDeclarationAfterType: false 40 | NamespaceIndentation: None 41 | 42 | # Spaces 43 | SpacesBeforeTrailingComments: 1 44 | SpacesInParentheses: false 45 | SpacesInAngles: false 46 | SpaceInEmptyParentheses: false 47 | SpacesInCStyleCastParentheses: false 48 | SpacesInContainerLiterals: true 49 | SpaceBeforeAssignmentOperators: true 50 | SpaceBeforeParens: ControlStatements 51 | PointerAlignment: Left 52 | DerivePointerAlignment: false 53 | AlignEscapedNewlines: DontAlign 54 | AlignTrailingComments: true 55 | Cpp11BracedListStyle: true 56 | 57 | # Misc 58 | ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH ] 59 | ... 60 | -------------------------------------------------------------------------------- /tests/TaskExtensionTest.cpp: -------------------------------------------------------------------------------- 1 | #include "TaskExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "Catch2QtUtils.h" 10 | #include "TextUtils.h" 11 | 12 | TEST_CASE("taskextension") { 13 | QMainWindow window; 14 | TextEdit* edit = new TextEdit; 15 | window.setCentralWidget(edit); 16 | TaskExtension extension(edit); 17 | edit->addExtension(&extension); 18 | 19 | SECTION("Toggle unchecked task") { 20 | setupTextEditContent(edit, "- [ ] task|"); 21 | extension.insertOrToggleTask(); 22 | REQUIRE(dumpTextEditContent(edit) == QString("- [x] task|")); 23 | } 24 | 25 | SECTION("Toggle checked task") { 26 | setupTextEditContent(edit, "- [x] task|"); 27 | extension.insertOrToggleTask(); 28 | REQUIRE(dumpTextEditContent(edit) == QString("- [ ] task|")); 29 | } 30 | 31 | SECTION("Insert task") { 32 | setupTextEditContent(edit, "ta|sk"); 33 | extension.insertOrToggleTask(); 34 | REQUIRE(dumpTextEditContent(edit) == QString("- [ ] ta|sk")); 35 | } 36 | 37 | SECTION("Insert task in indented line") { 38 | setupTextEditContent(edit, " |task"); 39 | extension.insertOrToggleTask(); 40 | REQUIRE(dumpTextEditContent(edit) == QString(" - [ ] |task")); 41 | } 42 | 43 | SECTION("Insert task in existing list") { 44 | setupTextEditContent(edit, "- task|"); 45 | extension.insertOrToggleTask(); 46 | REQUIRE(dumpTextEditContent(edit) == QString("- [ ] task|")); 47 | } 48 | 49 | SECTION("Insert task in existing indented list") { 50 | setupTextEditContent(edit, " - task|"); 51 | extension.insertOrToggleTask(); 52 | REQUIRE(dumpTextEditContent(edit) == QString(" - [ ] task|")); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.17) 2 | project(nanonote 3 | VERSION 1.4.1 4 | DESCRIPTION "Minimalist note taking application for short-lived notes" 5 | HOMEPAGE_URL "https://github.com/agateau/nanonote" 6 | ) 7 | include(CTest) 8 | 9 | set(AUTHOR_NAME "Aurélien Gâteau") 10 | set(AUTHOR_EMAIL "mail@agateau.com") 11 | set(ORGANIZATION_NAME "agateau.com") 12 | set(INVERSE_ORGANIZATION_NAME "com.agateau") 13 | 14 | set(APP_HUMAN_NAME "Nanonote") 15 | 16 | set(APP_NAME ${PROJECT_NAME}) 17 | set(APPLIB_NAME ${PROJECT_NAME}lib) 18 | 19 | list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) 20 | 21 | # Dependencies 22 | find_package(Qt5 CONFIG REQUIRED Core Widgets Test LinguistTools) 23 | find_package(ECM REQUIRED NO_MODULE) 24 | list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) 25 | 26 | # Installation directories 27 | if (UNIX AND NOT APPLE) 28 | include(GNUInstallDirs) 29 | set(DATA_INSTALL_DIR "${CMAKE_INSTALL_DATADIR}/${APP_NAME}") 30 | set(BIN_INSTALL_DIR "bin") 31 | set(BIN_TO_DATA_DIR "../${DATA_INSTALL_DIR}") 32 | endif() 33 | if (WIN32) 34 | set(DATA_INSTALL_DIR ".") 35 | set(BIN_INSTALL_DIR ".") 36 | set(BIN_TO_DATA_DIR ".") 37 | endif() 38 | if (APPLE) 39 | set(DATA_INSTALL_DIR "${APP_NAME}.app/Contents/Resources") 40 | set(BIN_INSTALL_DIR "${APP_NAME}.app/Contents/MacOS") 41 | set(BIN_TO_DATA_DIR "../Resources") 42 | endif() 43 | 44 | # Build flags 45 | if (CMAKE_COMPILER_IS_GNUCXX OR CMAKE_COMPILER_IS_CLANGXX) 46 | add_compile_options(-Wall -Woverloaded-virtual) 47 | endif() 48 | set(CMAKE_CXX_STANDARD 17) 49 | set(CMAKE_AUTOMOC ON) 50 | set(CMAKE_AUTOUIC ON) 51 | set(CMAKE_INSTALL_RPATH_USE_LINK_PATH ON) 52 | 53 | # Source dirs 54 | add_subdirectory(third-party) 55 | add_subdirectory(src) 56 | if (BUILD_TESTING) 57 | enable_testing() # must come *before* adding tests directory 58 | add_subdirectory(tests) 59 | endif() 60 | add_subdirectory(packaging) 61 | -------------------------------------------------------------------------------- /src/MainWindow.h: -------------------------------------------------------------------------------- 1 | #ifndef MAINWINDOW_H 2 | #define MAINWINDOW_H 3 | 4 | #include 5 | #include 6 | 7 | #include "TextEdit.h" 8 | 9 | class Settings; 10 | class TextEdit; 11 | 12 | class QAction; 13 | class QTimer; 14 | 15 | class MainWindowExtension; 16 | class SettingsDialog; 17 | class SearchWidget; 18 | 19 | class MainWindow; 20 | 21 | class MainWindowExtension : public TextEditExtension { 22 | public: 23 | explicit MainWindowExtension(MainWindow* window); 24 | 25 | void aboutToShowContextMenu(QMenu* menu, const QPoint& /*pos*/) override; 26 | void aboutToShowViewContextMenu(QMenu* menu, const QPoint& /*pos*/) override; 27 | 28 | private: 29 | MainWindow* mWindow; 30 | }; 31 | 32 | class MainWindow : public QMainWindow { 33 | Q_OBJECT 34 | 35 | public: 36 | MainWindow(QWidget* parent = 0); 37 | ~MainWindow(); 38 | 39 | private: 40 | void setupTextEdit(); 41 | void setupSearchBar(); 42 | void setupAutoSaveTimer(); 43 | void setupActions(); 44 | void loadNotes(); 45 | void saveNotes(); 46 | void loadSettings(); 47 | void saveSettings(); 48 | void adjustFontSize(int delta); 49 | void resetFontSize(); 50 | void setAlwaysOnTop(bool onTop); 51 | void showSettingsDialog(); 52 | void showSearchBar(); 53 | void hideSearchBar(); 54 | 55 | Settings* const mSettings; 56 | TextEdit* const mTextEdit; 57 | QTimer* const mAutoSaveTimer; 58 | SearchWidget* const mSearchWidget; 59 | QToolBar* const mSearchToolBar; 60 | 61 | QAction* const mIncreaseFontAction; 62 | QAction* const mDecreaseFontAction; 63 | QAction* const mResetFontAction; 64 | QAction* const mAlwaysOnTopAction; 65 | QAction* const mSettingsAction; 66 | QAction* const mSearchAction; 67 | QAction* const mCloseSearchAction; 68 | 69 | QPointer mSettingsDialog; 70 | 71 | friend class MainWindowExtension; 72 | }; 73 | 74 | #endif // MAINWINDOW_H 75 | -------------------------------------------------------------------------------- /src/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include "Settings.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | Settings::Settings(QObject* parent) : QObject(parent) { 8 | } 9 | 10 | QString Settings::notePath() { 11 | QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); 12 | return dirPath + "/nanonote.txt"; 13 | } 14 | 15 | void Settings::load() { 16 | QSettings settings; 17 | QRect geometry = settings.value("geometry").toRect(); 18 | if (geometry.isValid()) { 19 | setGeometry(geometry); 20 | } 21 | 22 | QVariant fontVariant = settings.value("font"); 23 | if (fontVariant.canConvert()) { 24 | setFont(fontVariant.value()); 25 | } else { 26 | setFont(defaultFont()); 27 | } 28 | 29 | setAlwaysOnTop(settings.value("alwaysOnTop").toBool()); 30 | } 31 | 32 | void Settings::save() { 33 | QSettings settings; 34 | settings.setValue("geometry", geometry()); 35 | settings.setValue("font", font()); 36 | settings.setValue("alwaysOnTop", alwaysOnTop()); 37 | } 38 | 39 | QFont Settings::defaultFont() const { 40 | return QFontDatabase::systemFont(QFontDatabase::FixedFont); 41 | } 42 | 43 | bool Settings::alwaysOnTop() const { 44 | return mAlwaysOnTop; 45 | } 46 | 47 | void Settings::setAlwaysOnTop(bool value) { 48 | if (mAlwaysOnTop == value) { 49 | return; 50 | } 51 | mAlwaysOnTop = value; 52 | alwaysOnTopChanged(value); 53 | } 54 | 55 | QFont Settings::font() const { 56 | return mFont; 57 | } 58 | 59 | void Settings::setFont(const QFont& value) { 60 | if (mFont == value) { 61 | return; 62 | } 63 | mFont = value; 64 | fontChanged(value); 65 | } 66 | 67 | QRect Settings::geometry() const { 68 | return mGeometry; 69 | } 70 | 71 | void Settings::setGeometry(const QRect& value) { 72 | if (mGeometry == value) { 73 | return; 74 | } 75 | mGeometry = value; 76 | geometryChanged(value); 77 | } 78 | -------------------------------------------------------------------------------- /tests/TextUtils.cpp: -------------------------------------------------------------------------------- 1 | #include "TextUtils.h" 2 | 3 | #include "TextEdit.h" 4 | 5 | #include 6 | 7 | static constexpr char SELECTION_START_CH = '*'; 8 | static constexpr char SELECTION_END_CH = '|'; 9 | 10 | using std::optional; 11 | 12 | QString dumpTextEditContent(TextEdit* edit) { 13 | QString dump = edit->toPlainText(); 14 | int start = edit->textCursor().selectionStart(); 15 | int end = edit->textCursor().selectionEnd(); 16 | int pos = edit->textCursor().position(); 17 | if (start == end) { 18 | // No selection 19 | dump.insert(start, SELECTION_END_CH); 20 | return dump; 21 | } 22 | char startCh = SELECTION_START_CH; 23 | char endCh = SELECTION_END_CH; 24 | if (pos == start) { 25 | std::swap(startCh, endCh); 26 | } 27 | // Insert the selection indicator chars. Make sure we start from the end 28 | // one since inserting changes the indices 29 | dump.insert(end, endCh); 30 | dump.insert(start, startCh); 31 | return dump; 32 | } 33 | 34 | void setupTextEditContent(TextEdit* edit, const QString& text) { 35 | QString realText; 36 | optional start, end; 37 | int current = 0; 38 | for (const auto& qChar : text) { 39 | char ch = qChar.toLatin1(); 40 | switch (ch) { 41 | case SELECTION_START_CH: 42 | Q_ASSERT(!start.has_value()); 43 | start = current; 44 | break; 45 | case SELECTION_END_CH: 46 | Q_ASSERT(!end.has_value()); 47 | end = current; 48 | break; 49 | default: 50 | realText += ch; 51 | ++current; 52 | } 53 | } 54 | edit->setPlainText(realText); 55 | auto cursor = edit->textCursor(); 56 | Q_ASSERT(end.has_value()); 57 | if (start.has_value()) { 58 | cursor.setPosition(start.value()); 59 | cursor.setPosition(end.value(), QTextCursor::KeepAnchor); 60 | } else { 61 | cursor.setPosition(end.value()); 62 | } 63 | edit->setTextCursor(cursor); 64 | } 65 | -------------------------------------------------------------------------------- /ci/lib/common-dependencies.sh: -------------------------------------------------------------------------------- 1 | AQTINSTALL_VERSION=3.3.0 2 | AQTINSTALL_ARCHIVES="qtbase qtimageformats qtsvg qttranslations qttools" 3 | 4 | check_pipx() { 5 | echo_title "Looking for pipx" 6 | if command -v pipx 2> /dev/null ; then 7 | echo "Found pipx" 8 | return 9 | fi 10 | die "Can't find pipx." 11 | } 12 | 13 | install_qt() { 14 | echo_title "Installing Qt" 15 | local qt_install_dir=$INST_DIR/qt 16 | local aqt_args 17 | if is_windows ; then 18 | aqt_args="windows desktop $QT_VERSION $QT_ARCH_WINDOWS" 19 | fi 20 | if is_macos ; then 21 | aqt_args="mac desktop $QT_VERSION $QT_ARCH_MACOS" 22 | fi 23 | pipx install aqtinstall==$AQTINSTALL_VERSION 24 | 25 | aqt install-qt \ 26 | $aqt_args \ 27 | --outputdir $qt_install_dir \ 28 | --archives $AQTINSTALL_ARCHIVES 29 | 30 | if is_windows ; then 31 | # Add Qt bin dir to $PATH so that tests can find Qt dlls 32 | prepend_path $(find $qt_install_dir -type d -a -name bin) 33 | fi 34 | # Add Qt plugins dir to $QT_PLUGIN_PATH because the official Qt installer 35 | # patches QtCore dll so that it finds its plugins, but aqt does not. 36 | # Not being able to find plugins causes tests to not run on macOS and 37 | # Windows because they can't find the matching platform plugin. 38 | add_env_var QT_PLUGIN_PATH $(find $qt_install_dir -type d -a -name plugins) 39 | add_env_var Qt5_DIR $(find $qt_install_dir -path '*/lib/cmake') 40 | } 41 | 42 | install_cmake() { 43 | echo_title "Installing CMake" 44 | pipx install cmake==$CMAKE_VERSION 45 | } 46 | 47 | install_ecm() { 48 | echo_title "Installing ECM" 49 | git clone --depth 1 https://anongit.kde.org/extra-cmake-modules.git -b v$ECM_VERSION 50 | ( 51 | cd extra-cmake-modules 52 | mkdir build 53 | cd build 54 | cmake \ 55 | -DCMAKE_INSTALL_PREFIX=$INST_DIR \ 56 | -DBUILD_HTML_DOCS=OFF \ 57 | -DBUILD_MAN_DOCS=OFF \ 58 | -DBUILD_QTHELP_DOCS=OFF \ 59 | -DBUILD_TESTING=OFF \ 60 | -G "$CMAKE_GENERATOR" \ 61 | .. 62 | cmake --build . 63 | cmake --build . --target install 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /changelog.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from pathlib import Path 3 | from typing import Any, Iterable 4 | 5 | 6 | @dataclass 7 | class Release: 8 | version: str 9 | date: str 10 | # type => [changes] 11 | changes: dict[str, list[str]] = field(default_factory=dict) 12 | 13 | 14 | @dataclass 15 | class Changelog: 16 | # version => release 17 | releases: dict[str, Release] = field(default_factory=dict) 18 | 19 | @staticmethod 20 | def from_path(changelog_path: Path) -> "Changelog": 21 | with changelog_path.open() as f: 22 | parser = Parser(f) 23 | return parser.parse() 24 | 25 | 26 | def _get_dict_last_added_item(dct: dict[Any, Any]) -> Any: 27 | return list(dct.values())[-1] 28 | 29 | 30 | class Parser: 31 | def __init__(self, line_it: Iterable[str]): 32 | self.changelog = Changelog() 33 | self.line_it = line_it 34 | 35 | def parse(self) -> Changelog: 36 | self._parser = self._parse_prolog 37 | for line in self.line_it: 38 | line = line.strip() 39 | if line: 40 | self._parser(line) 41 | 42 | return self.changelog 43 | 44 | def _parse_prolog(self, line: str) -> None: 45 | if line.startswith("## "): 46 | self._parse_release_title(line) 47 | self._parser = self._parse_release_content 48 | 49 | def _parse_release_title(self, line: str) -> None: 50 | version, date = line[3:].split(" - ", maxsplit=1) 51 | release = Release(version=version, date=date) 52 | self.changelog.releases[version] = release 53 | 54 | def _parse_release_content(self, line: str) -> None: 55 | if line.startswith("## "): 56 | self._parse_release_title(line) 57 | return 58 | 59 | release = _get_dict_last_added_item(self.changelog.releases) 60 | 61 | if line.startswith("### "): 62 | change_type = line[4:] 63 | release.changes[change_type] = [] 64 | else: 65 | assert line.startswith("- "), line 66 | current_changes = _get_dict_last_added_item(release.changes) 67 | current_changes.append(line[2:]) 68 | 69 | 70 | if __name__ == "__main__": 71 | changelog = Changelog.from_path(Path("CHANGELOG.md")) 72 | print(changelog) 73 | -------------------------------------------------------------------------------- /src/SearchWidget.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SearchWidget 4 | 5 | 6 | 7 | 0 8 | 0 9 | 832 10 | 48 11 | 12 | 13 | 14 | 15 | 16 | 17 | [count] 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | true 31 | 32 | 33 | 34 | 35 | 36 | 37 | Previous 38 | 39 | 40 | Qt::ToolButtonTextBesideIcon 41 | 42 | 43 | true 44 | 45 | 46 | Qt::UpArrow 47 | 48 | 49 | 50 | 51 | 52 | 53 | Next 54 | 55 | 56 | 57 | 16 58 | 16 59 | 60 | 61 | 62 | Qt::ToolButtonTextBesideIcon 63 | 64 | 65 | true 66 | 67 | 68 | Qt::DownArrow 69 | 70 | 71 | 72 | 73 | 74 | 75 | lineEdit 76 | previousButton 77 | nextButton 78 | closeButton 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include "MainWindow.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | #include 13 | 14 | #include "BuildConfig.h" 15 | #include "Resources.h" 16 | 17 | static void loadTranslations(QObject* parent) { 18 | QLocale locale; 19 | 20 | auto qtTranslator = new QTranslator(parent); 21 | auto qtTranslationsDir = QLibraryInfo::location(QLibraryInfo::TranslationsPath); 22 | if (qtTranslator->load(locale, "qtbase", "_", qtTranslationsDir)) { 23 | QCoreApplication::installTranslator(qtTranslator); 24 | } 25 | 26 | std::optional translationsDir = Resources::findDir("translations"); 27 | if (!translationsDir.has_value()) { 28 | return; 29 | } 30 | auto translator = new QTranslator(parent); 31 | if (translator->load(locale, APP_NAME, "_", translationsDir.value())) { 32 | QCoreApplication::installTranslator(translator); 33 | } 34 | } 35 | 36 | /** 37 | * Initialize QIcon so that QIcon::fromTheme() finds our icons on Windows and macOS 38 | */ 39 | static void initFallbackIcons() { 40 | #if defined(Q_OS_WINDOWS) || defined(Q_OS_MACOS) 41 | // A theme name must be defined othewise QIcon::fromTheme won't look in fallbackSearchPaths 42 | QIcon::setThemeName(APP_NAME); 43 | QIcon::setFallbackSearchPaths(QIcon::fallbackSearchPaths() << ":/icons"); 44 | #endif 45 | } 46 | 47 | int main(int argc, char* argv[]) { 48 | SingleApplication app(argc, argv); 49 | Q_INIT_RESOURCE(app); 50 | app.setOrganizationName(ORGANIZATION_NAME); 51 | app.setApplicationName(APP_NAME); 52 | app.setApplicationVersion(APP_VERSION); 53 | auto iconName = QString(":/appicon/sc-apps-%1.svg").arg(APP_NAME); 54 | app.setWindowIcon(QIcon(iconName)); 55 | app.setAttribute(Qt::AA_UseHighDpiPixmaps); 56 | #ifdef Q_OS_MACOS 57 | app.setAttribute(Qt::AA_DontShowShortcutsInContextMenus, false); 58 | QGuiApplication::styleHints()->setShowShortcutsInContextMenus(true); 59 | #endif 60 | 61 | initFallbackIcons(); 62 | 63 | loadTranslations(&app); 64 | 65 | MainWindow window; 66 | 67 | QObject::connect(&app, &SingleApplication::instanceStarted, [&window] { 68 | window.raise(); 69 | window.activateWindow(); 70 | }); 71 | 72 | window.show(); 73 | 74 | return app.exec(); 75 | } 76 | -------------------------------------------------------------------------------- /ci/lib/common.sh: -------------------------------------------------------------------------------- 1 | echo_title() { 2 | echo -e "\033[34m$*\033[0m" 3 | } 4 | 5 | detect_os() { 6 | local out 7 | out=$(uname) 8 | 9 | case "$out" in 10 | Linux) 11 | OS="linux" 12 | ;; 13 | Darwin) 14 | OS="macos" 15 | ;; 16 | MINGW*) 17 | OS="windows" 18 | ;; 19 | *) 20 | echo "Unknown OS. uname printed '$out'" 21 | exit 1 22 | ;; 23 | esac 24 | } 25 | 26 | is_linux() { 27 | [ "$OS" = "linux" ] 28 | } 29 | 30 | is_macos() { 31 | [ "$OS" = "macos" ] 32 | } 33 | 34 | is_windows() { 35 | [ "$OS" = "windows" ] 36 | } 37 | 38 | has_command() { 39 | command -v "$1" > /dev/null 2>&1 40 | } 41 | 42 | die() { 43 | echo "ERROR: $*" >&2 44 | exit 1 45 | } 46 | 47 | mkabsdir() { 48 | mkdir -p "$1" 49 | pushd $PWD > /dev/null 50 | cd "$1" 51 | echo $PWD 52 | pop > /dev/null 53 | } 54 | 55 | init_run_as_root() { 56 | RUN_AS_ROOT="" 57 | if is_windows ; then 58 | return 59 | fi 60 | if [ $(id -u) = "0" ] ; then 61 | # Already root 62 | return 63 | fi 64 | if has_command sudo ; then 65 | RUN_AS_ROOT=sudo 66 | else 67 | RUN_AS_ROOT="su -c" 68 | fi 69 | } 70 | 71 | download_prebuilt_file() { 72 | local url=$1 73 | local sha256=$2 74 | local download_file=$3 75 | 76 | echo "Downloading '$url'" 77 | curl --location --continue-at - --output "$download_file" "$url" 78 | 79 | echo "Checking integrity" 80 | echo "$sha256 $download_file" | sha256sum --check 81 | } 82 | 83 | install_prebuilt_archive() { 84 | local url=$1 85 | local sha256=$2 86 | local download_file=$3 87 | local unpack_dir=$4 88 | 89 | download_prebuilt_file $url $sha256 $download_file 90 | 91 | echo "Unpacking" 92 | ( 93 | cd "$unpack_dir" 94 | case "$download_file" in 95 | *.zip) 96 | unzip -q "$download_file" 97 | ;; 98 | *.tar.gz|*.tar.bz2|*.tar.xz) 99 | tar xf "$download_file" 100 | ;; 101 | *) 102 | die "Don't know how to unpack $download_file" 103 | ;; 104 | esac 105 | ) 106 | } 107 | 108 | install_prebuilt_executable() { 109 | local url=$1 110 | local sha256=$2 111 | local download_file=$3 112 | 113 | download_prebuilt_file $url $sha256 $download_file 114 | chmod +x $download_file 115 | } 116 | 117 | detect_os 118 | init_run_as_root 119 | 120 | if is_macos ; then 121 | NPROC=$(sysctl -n hw.ncpu) 122 | else 123 | NPROC=$(nproc) 124 | fi 125 | -------------------------------------------------------------------------------- /packaging/macos/generate-ds-store: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Generates the .DS_Store used to customize the layout of the Finder window in a 4 | macOS DMG. 5 | 6 | You can change the layout by editing this file. For more control consider using 7 | dmgbuild . 8 | """ 9 | import sys 10 | from argparse import ArgumentParser 11 | 12 | from ds_store import DSStore 13 | 14 | WINDOW_RECT = ((100, 100), (500, 280)) 15 | ICON_SIZE = 128 16 | TEXT_SIZE = 12 17 | 18 | APP_POSITION = (40, 110) 19 | APP_DIR_SYMLINK_POSITION = (300, 110) 20 | 21 | 22 | def move_file(store_item, pos): 23 | store_item["Iloc"] = pos 24 | 25 | 26 | def set_window_options(store_item): 27 | position, size = WINDOW_RECT 28 | bounds_str = "{{%d, %d}, {%d, %d}}" % (position[0], position[1], size[0], size[1]) 29 | store_item["bwsp"] = { 30 | "ShowStatusBar": False, 31 | "WindowBounds": bounds_str, 32 | "ContainerShowSidebar": False, 33 | "PreviewPaneVisibility": False, 34 | "SidebarWidth": 180, 35 | "ShowTabView": False, 36 | "ShowToolbar": False, 37 | "ShowPathbar": False, 38 | "ShowSidebar": False, 39 | } 40 | 41 | # Use icon view by default 42 | store_item["icvl"] = ("type", "icnv") 43 | 44 | 45 | def set_icon_view_options(store_item): 46 | store_item["icvp"] = { 47 | "viewOptionsVersion": 1, 48 | "backgroundType": 0, 49 | "backgroundColorRed": 1.0, 50 | "backgroundColorGreen": 1.0, 51 | "backgroundColorBlue": 1.0, 52 | "gridOffsetX": 0.0, 53 | "gridOffsetY": 0.0, 54 | "gridSpacing": 100.0, 55 | "arrangeBy": "none", 56 | "showIconPreview": True, 57 | "showItemInfo": True, 58 | "labelOnBottom": True, 59 | "iconSize": float(ICON_SIZE), 60 | "textSize": float(TEXT_SIZE), 61 | "scrollPositionX": 0.0, 62 | "scrollPositionY": 0.0, 63 | } 64 | 65 | 66 | def main(): 67 | parser = ArgumentParser() 68 | parser.description = __doc__ 69 | parser.add_argument("app_name") 70 | parser.add_argument("ds_store_path") 71 | 72 | args = parser.parse_args() 73 | 74 | with DSStore.open(args.ds_store_path, "w+") as f: 75 | # Always present for directories according to 76 | # https://metacpan.org/pod/distribution/Mac-Finder-DSStore/DSStoreFormat.pod#'vSrn' 77 | f["."]["vSrn"] = ("long", 1) 78 | 79 | set_window_options(f["."]) 80 | set_icon_view_options(f["."]) 81 | move_file(f[args.app_name + ".app"], APP_POSITION) 82 | move_file(f["Applications"], APP_DIR_SYMLINK_POSITION) 83 | 84 | 85 | if __name__ == "__main__": 86 | sys.exit(main()) 87 | -------------------------------------------------------------------------------- /src/LinkExtension.cpp: -------------------------------------------------------------------------------- 1 | #include "LinkExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "SyntaxHighlighter.h" 11 | 12 | static QUrl getLinkUnderCursor(const QTextCursor& cursor) { 13 | return SyntaxHighlighter::getLinkAt(cursor.block().text(), cursor.positionInBlock()); 14 | } 15 | 16 | LinkExtension::LinkExtension(TextEdit* textEdit) 17 | : TextEditExtension(textEdit), mOpenLinkAction(std::make_unique()) { 18 | mOpenLinkAction->setText(tr("Go to link")); 19 | mOpenLinkAction->setShortcut(Qt::CTRL | Qt::Key_G); 20 | connect(mOpenLinkAction.get(), &QAction::triggered, this, &LinkExtension::openLinkUnderCursor); 21 | mTextEdit->addAction(mOpenLinkAction.get()); 22 | } 23 | 24 | void LinkExtension::aboutToShowContextMenu(QMenu* menu, const QPoint& pos) { 25 | auto cursor = mTextEdit->cursorForPosition(pos); 26 | QUrl url = getLinkUnderCursor(cursor); 27 | if (!url.isValid()) { 28 | return; 29 | } 30 | menu->addAction(tr("Copy link address"), this, [url] { 31 | auto data = new QMimeData; 32 | data->setUrls({url}); 33 | qGuiApp->clipboard()->setMimeData(data); 34 | }); 35 | menu->addAction(mOpenLinkAction.get()->text(), 36 | this, 37 | [url] { QDesktopServices::openUrl(url); }, 38 | mOpenLinkAction.get()->shortcut()); 39 | } 40 | 41 | bool LinkExtension::keyPress(QKeyEvent* event) { 42 | if (event->modifiers() == Qt::CTRL) { 43 | mTextEdit->viewport()->setMouseTracking(true); 44 | updateMouseCursor(); 45 | } 46 | return false; 47 | } 48 | 49 | bool LinkExtension::keyRelease(QKeyEvent* event) { 50 | if (event->modifiers() != Qt::CTRL) { 51 | reset(); 52 | } 53 | return false; 54 | } 55 | 56 | bool LinkExtension::mouseMove(QMouseEvent* event) { 57 | updateMouseCursor(); 58 | return false; 59 | } 60 | 61 | bool LinkExtension::mouseRelease(QMouseEvent* event) { 62 | if (event->modifiers() == Qt::CTRL) { 63 | openLinkUnderCursor(); 64 | } 65 | return false; 66 | } 67 | 68 | void LinkExtension::openLinkUnderCursor() { 69 | QUrl url = getLinkUnderCursor(mTextEdit->textCursor()); 70 | if (url.isValid()) { 71 | QDesktopServices::openUrl(url); 72 | } 73 | } 74 | 75 | void LinkExtension::updateMouseCursor() { 76 | auto mousePos = mTextEdit->viewport()->mapFromGlobal(QCursor::pos()); 77 | auto textCursor = mTextEdit->cursorForPosition(mousePos); 78 | auto shape = 79 | getLinkUnderCursor(textCursor).isValid() ? Qt::PointingHandCursor : Qt::IBeamCursor; 80 | mTextEdit->viewport()->setCursor(shape); 81 | } 82 | 83 | void LinkExtension::reset() { 84 | mTextEdit->viewport()->setMouseTracking(false); 85 | mTextEdit->viewport()->setCursor(Qt::IBeamCursor); 86 | } 87 | -------------------------------------------------------------------------------- /src/SettingsDialog.cpp: -------------------------------------------------------------------------------- 1 | #include "SettingsDialog.h" 2 | #include "ui_SettingsDialog.h" 3 | 4 | #include 5 | #include 6 | 7 | #include "Settings.h" 8 | 9 | static constexpr char PROJECT_URL[] = "https://github.com/agateau/nanonote/"; 10 | static constexpr char SUPPORT_URL[] = "https://agateau.com/support/"; 11 | static constexpr char TIPS_URL[] = "https://github.com/agateau/nanonote/blob/master/docs/tips.md"; 12 | 13 | SettingsDialog::SettingsDialog(Settings* settings, QWidget* parent) 14 | : QDialog(parent), ui(new Ui::SettingsDialog), mSettings(settings) { 15 | ui->setupUi(this); 16 | setupConfigTab(); 17 | setupAboutTab(); 18 | ui->tabWidget->setCurrentIndex(0); 19 | 20 | updateFontFromSettings(); 21 | 22 | connect(mSettings, &Settings::fontChanged, this, &SettingsDialog::updateFontFromSettings); 23 | 24 | connect(ui->fontComboBox, &QFontComboBox::currentFontChanged, mSettings, &Settings::setFont); 25 | connect(ui->fontSizeSpinBox, 26 | static_cast(&QSpinBox::valueChanged), 27 | this, 28 | [this](int value) { 29 | auto font = mSettings->font(); 30 | font.setPointSize(value); 31 | mSettings->setFont(font); 32 | }); 33 | } 34 | 35 | SettingsDialog::~SettingsDialog() { 36 | delete ui; 37 | } 38 | 39 | void SettingsDialog::setupConfigTab() { 40 | auto url = QUrl::fromLocalFile(Settings::notePath()); 41 | auto noteLink = 42 | QString("%2").arg(url.toEncoded(), Settings::notePath()); 43 | ui->noteLocationLabel->setText(noteLink); 44 | } 45 | 46 | void SettingsDialog::setupAboutTab() { 47 | // Do not use C++ raw strings here, the lupdate shipped with Ubuntu 18.04 does not understand 48 | // them 49 | auto text = tr("

Nanonote %1

\n" 50 | "

A minimalist note taking application.

\n" 51 | "

\n" 52 | "• Project page: %2
\n" 53 | "• Tips and tricks: %3\n" 54 | "

\n", 55 | "%1: version, %2: project url, %3: tips and trick page url") 56 | .arg(qApp->applicationVersion(), PROJECT_URL, TIPS_URL); 57 | ui->aboutLabel->setText(text); 58 | 59 | text = tr("

Hi,

\n" 60 | "

I hope you enjoy Nanonote!

\n" 61 | "

If you do, it would be lovely if you could support my work on " 62 | "free and open source software.

\n" 63 | "

― Aurélien

", 64 | "%1: support url") 65 | .arg(SUPPORT_URL); 66 | auto font = ui->supportLabel->font(); 67 | font.setItalic(true); 68 | ui->supportLabel->setFont(font); 69 | ui->supportLabel->setText(text); 70 | } 71 | 72 | void SettingsDialog::updateFontFromSettings() { 73 | ui->fontComboBox->setCurrentFont(mSettings->font()); 74 | ui->fontSizeSpinBox->setValue(mSettings->font().pointSize()); 75 | } 76 | -------------------------------------------------------------------------------- /src/SyntaxHighlighter.cpp: -------------------------------------------------------------------------------- 1 | #include "SyntaxHighlighter.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | // These chars are allowed anywhere in the url 8 | #define COMMON_CHARS "-_a-zA-Z0-9/?=&#" 9 | 10 | // These chars are not allowed at the end of the url, because they can be used 11 | // as punctuation, are separators in urls or are unlikely to appear at the end of an url. 12 | #define MIDDLE_CHARS ".,;:%+~@" 13 | 14 | static constexpr char LINK_REGEX[] = 15 | "\\b(https?://|ftp://|file:/)[" COMMON_CHARS MIDDLE_CHARS "]+[" COMMON_CHARS "]"; 16 | 17 | static constexpr char TASK_REGEX[] = "^\\s*[-\\*] (\\[[x ]\\])"; 18 | 19 | static constexpr char HEADING_REGEX[] = "^#+ .*$"; 20 | 21 | SyntaxHighlighter::SyntaxHighlighter(QTextDocument* document) 22 | : QSyntaxHighlighter(document) 23 | , mLinkRegex(LINK_REGEX) 24 | , mTaskRegex(TASK_REGEX) 25 | , mHeadingRegex(HEADING_REGEX) { 26 | } 27 | 28 | void SyntaxHighlighter::highlightBlock(const QString& text) { 29 | QTextCharFormat headingFormat; 30 | headingFormat.setFontWeight(QFont::Bold); 31 | 32 | for (auto it = mHeadingRegex.globalMatch(text); it.hasNext();) { 33 | QRegularExpressionMatch match = it.next(); 34 | setFormat(match.capturedStart(), match.capturedLength(), headingFormat); 35 | } 36 | 37 | QTextCharFormat linkFormat; 38 | QColor linkColor = QGuiApplication::palette().color(QPalette::Link); 39 | linkFormat.setForeground(linkColor); 40 | linkFormat.setUnderlineColor(linkColor); 41 | linkFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline); 42 | 43 | for (auto it = mLinkRegex.globalMatch(text); it.hasNext();) { 44 | QRegularExpressionMatch match = it.next(); 45 | setFormat(match.capturedStart(), match.capturedLength(), linkFormat); 46 | } 47 | 48 | QTextCharFormat taskFormat; 49 | taskFormat.setForeground(linkColor); 50 | 51 | for (auto it = mTaskRegex.globalMatch(text); it.hasNext();) { 52 | QRegularExpressionMatch match = it.next(); 53 | setFormat(match.capturedStart(1), match.capturedLength(1), taskFormat); 54 | } 55 | } 56 | 57 | QUrl SyntaxHighlighter::getLinkAt(const QString& text, int position) { 58 | QRegularExpression expression(LINK_REGEX); 59 | for (auto it = expression.globalMatch(text); it.hasNext();) { 60 | QRegularExpressionMatch match = it.next(); 61 | if (match.capturedStart() <= position && position < match.capturedEnd()) { 62 | return QUrl::fromUserInput(match.captured()); 63 | } 64 | } 65 | return QUrl(); 66 | } 67 | 68 | int SyntaxHighlighter::getTaskCheckmarkPosAt(const QString& text, int position) { 69 | QRegularExpression expression(TASK_REGEX); 70 | for (auto it = expression.globalMatch(text); it.hasNext();) { 71 | QRegularExpressionMatch match = it.next(); 72 | if (match.capturedStart(1) <= position && position < match.capturedEnd(1)) { 73 | return match.capturedStart(1) + 1; // Position of 'x' or ' ' character 74 | } 75 | } 76 | return -1; 77 | } 78 | -------------------------------------------------------------------------------- /.changes/1.3.91.md: -------------------------------------------------------------------------------- 1 | ## 1.3.91 - 2023-03-12 2 | 3 | ### Added 4 | 5 | - Add support for Markdown-style tasks in lists (Daniel Laidig) 6 | - Add tips page (Aurelien Gateau) 7 | 8 | ### Changed 9 | 10 | - Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig) 11 | 12 | ### Fixed 13 | 14 | - Make sure standard actions like Copy or Paste are translated (Aurelien Gateau) 15 | - Show keyboard shortcuts in context menus on macOS (Daniel Laidig) 16 | - Do not change cursor to pointing-hand when not over a link (Aurelien Gateau) 17 | 18 | ### Internals 19 | 20 | - CI: Bump Ubuntu to 20.04 and macOS to 11 (Aurelien Gateau) 21 | - CI: Install clang-format from muttleyxd/clang-tools-static-binaries (Aurelien Gateau) 22 | - Bump Qt to 5.15.2 on macOS and Windows (Aurelien Gateau) 23 | - Update singleaplication to 3.3.4 (Aurelien Gateau) 24 | - Update Catch2 to 3.3.0 (Aurelien Gateau) 25 | 26 | ## 1.3.0 - 2020-10-03 27 | 28 | ### Changed 29 | 30 | - Update Spanish translation (Victorhck) 31 | 32 | ### Fixed 33 | 34 | - Properly encode URL of the note path (Aurelien Gateau) 35 | - Fix untranslated text in About tab on Linux (Aurelien Gateau) 36 | 37 | ## 1.2.91 - 2020-09-28 38 | 39 | ### Added 40 | 41 | - You can now search inside your notes with the new search bar (Pavol Oresky) 42 | - You can now move selected lines up and down with Alt+Shift+Up and Down (Aurelien Gateau) 43 | - macOS dmg (Aurelien Gateau) 44 | - Windows installer (Aurelien Gateau) 45 | 46 | ### Changed 47 | 48 | - Reorganized context menu: added "Edit" and "View" submenus (Aurelien Gateau) 49 | 50 | ## 1.2.0 - 2019-05-11 51 | 52 | ### Added 53 | 54 | - New German translation by Vinzenz Vietzke 55 | - Allow changing the font size using Ctrl + mouse wheel (Daniel Laidig) 56 | - Use the link color of the color theme instead of an hardcoded blue (Daniel Laidig) 57 | - Added a way to reset the font size to the default value (Daniel Laidig) 58 | 59 | ### Fixed 60 | 61 | - Added explanation of how to open URLs to the welcome text (Robert Barat) 62 | - Allow '@' in URLs (Aurelien Gateau) 63 | - Use QSaveFile for safer saving (Aurelien Gateau) 64 | 65 | ## 1.1.0 - 2019-02-04 66 | 67 | ### Added 68 | 69 | - Pressing tab now indents the whole line when the cursor is at the beginning of a list item (Daniel Laidig). 70 | - Pressing Enter on an empty list item now unindents, then removes the bullet (Aurelien Gateau). 71 | - Added French and Spanish translations (Aurelien Gateau, Victorhck). 72 | 73 | ### Fixed 74 | 75 | - Improved url detection: '+', '%' and '~' are now allowed in the middle of urls (Aurelien Gateau). 76 | - Fixed wrong indentation behavior in upward selections (Aurelien Gateau). 77 | 78 | ## 1.0.1 - 2019-01-12 79 | 80 | ### Added 81 | 82 | - Added unit-tests. 83 | - Added Travis integration. 84 | - Added rpm and deb packages generated using CPack. 85 | 86 | ### Fixed 87 | 88 | - Fixed indentation and make it respect indentation columns. 89 | - Made it possible to indent/unindent selected lines with Tab/Shift+Tab. 90 | - Update welcome text to reflect current shortcuts. 91 | 92 | ## 1.0.0 - 2018-12-30 93 | 94 | ### Added 95 | 96 | - First release 97 | -------------------------------------------------------------------------------- /packaging/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | if (APPLE) 2 | include(find_python_module) 3 | 4 | # If the ds_store Python module is available, we can generate a 5 | # .DS_Store file to customize the layout of the Finder window for the 6 | # macOS DMG. 7 | find_python_module(ds_store) 8 | if (NOT ds_store_FOUND) 9 | message("Python module ds_store not found: layout of the Finder window for the macOS DMG won't be customized") 10 | endif() 11 | endif() 12 | 13 | set(CPACK_PACKAGE_VENDOR ${ORGANIZATION_NAME}) 14 | 15 | set(CPACK_PACKAGE_VERSION_MAJOR ${PROJECT_VERSION_MAJOR}) 16 | set(CPACK_PACKAGE_VERSION_MINOR ${PROJECT_VERSION_MINOR}) 17 | set(CPACK_PACKAGE_VERSION_PATCH ${PROJECT_VERSION_PATCH}) 18 | 19 | set(CPACK_PACKAGE_CONTACT "${AUTHOR_NAME} <${AUTHOR_EMAIL}>") 20 | 21 | set(CPACK_PACKAGE_INSTALL_DIRECTORY ${PROJECT_NAME}) 22 | 23 | set(CPACK_PACKAGE_FILE_NAME ${PROJECT_NAME}-${PROJECT_VERSION}) 24 | 25 | if (EXISTS ${PROJECT_SOURCE_DIR}/LICENSE) 26 | set(CPACK_RESOURCE_FILE_LICENSE ${PROJECT_SOURCE_DIR}/LICENSE) 27 | endif() 28 | 29 | if (UNIX AND NOT APPLE) 30 | set(CPACK_GENERATOR "DEB;RPM") 31 | 32 | set(CPACK_DEBIAN_FILE_NAME "DEB-DEFAULT") 33 | set(CPACK_DEBIAN_PACKAGE_HOMEPAGE ${APP_URL}) 34 | set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) 35 | 36 | set(CPACK_RPM_FILE_NAME "RPM-DEFAULT") 37 | set(CPACK_RPM_PACKAGE_URL ${APP_URL}) 38 | endif() 39 | 40 | if (WIN32) 41 | set(CPACK_GENERATOR "NSIS") 42 | 43 | set(CPACK_NSIS_PACKAGE_NAME ${APP_HUMAN_NAME}) 44 | set(CPACK_NSIS_DISPLAY_NAME ${APP_HUMAN_NAME}) 45 | set(CPACK_NSIS_URL_INFO_ABOUT ${APP_URL}) 46 | set(CPACK_NSIS_CREATE_ICONS_EXTRA 47 | "CreateShortCut '$SMPROGRAMS\\\\$STARTMENU_FOLDER\\\\${APP_HUMAN_NAME}.lnk' '$INSTDIR\\\\${APP_NAME}.exe'") 48 | set(CPACK_NSIS_DELETE_ICONS_EXTRA 49 | "Delete '$SMPROGRAMS\\\\$START_MENU\\\\${APP_HUMAN_NAME}.lnk'") 50 | 51 | # Setting CPACK_NSIS_EXECUTABLES_DIRECTORY is required for 52 | # CPACK_NSIS_MUI_FINISHPAGE_RUN to find the executable. 53 | # 54 | # If not set, the default path is $INSTDIR/bin 55 | # 56 | # cf http://cmake.3232098.n2.nabble.com/Problems-with-CPack-NSIS-and-CPACK-NSIS-MUI-FINISHPAGE-RUN-td7003656.html 57 | set(CPACK_NSIS_EXECUTABLES_DIRECTORY ".") 58 | set(CPACK_NSIS_MUI_FINISHPAGE_RUN "${APP_NAME}.exe") 59 | endif() 60 | 61 | if (APPLE) 62 | set(CPACK_GENERATOR "DragNDrop") 63 | 64 | if (ds_store_FOUND) 65 | set(DS_STORE_FILE ${CMAKE_CURRENT_BINARY_DIR}/dot-DS_Store) 66 | set(GENERATE_DS_STORE_CMD ${CMAKE_CURRENT_SOURCE_DIR}/macos/generate-ds-store) 67 | add_custom_command( 68 | OUTPUT ${DS_STORE_FILE} 69 | COMMAND ${GENERATE_DS_STORE_CMD} ${APP_NAME} ${DS_STORE_FILE} 70 | DEPENDS ${GENERATE_DS_STORE_CMD} 71 | ) 72 | add_custom_target(generate_dsstore DEPENDS ${DS_STORE_FILE}) 73 | add_dependencies(${APP_NAME} generate_dsstore) 74 | 75 | set(CPACK_DMG_DS_STORE ${DS_STORE_FILE}) 76 | endif() 77 | 78 | set(CPACK_DMG_VOLUME_NAME ${APP_HUMAN_NAME}) 79 | endif() 80 | 81 | set(CPACK_SOURCE_GENERATOR "TBZ2") 82 | set(CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}") 83 | set(CPACK_SOURCE_IGNORE_FILES "/build/;/.git/;/__pycache__/;~$") 84 | 85 | include(CPack) 86 | -------------------------------------------------------------------------------- /src/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | include(translations) 2 | include(ECMAddAppIcon) 3 | 4 | add_subdirectory(appicon) 5 | 6 | # Sources 7 | configure_file( 8 | ${CMAKE_CURRENT_SOURCE_DIR}/BuildConfig.h.in 9 | ${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.h 10 | ) 11 | 12 | set(APPLIB_SRCS 13 | IndentExtension.cpp 14 | SyntaxHighlighter.cpp 15 | LinkExtension.cpp 16 | MainWindow.cpp 17 | MoveLinesExtension.cpp 18 | Resources.cpp 19 | Settings.cpp 20 | SettingsDialog.cpp 21 | SettingsDialog.ui 22 | TaskExtension.cpp 23 | TextEdit.cpp 24 | WheelZoomExtension.cpp 25 | SearchWidget.cpp 26 | SearchWidget.ui 27 | ) 28 | 29 | qt5_add_resources(APPLIB_RESOURCES_SRCS app.qrc) 30 | 31 | # App library 32 | add_library(${APPLIB_NAME} STATIC 33 | ${APPLIB_SRCS} 34 | ${QPROPGEN_SRCS} 35 | ${APPLIB_RESOURCES_SRCS} 36 | ${TRANSLATIONS_RESOURCES_SRCS} 37 | ) 38 | target_include_directories(${APPLIB_NAME} PUBLIC 39 | ${CMAKE_CURRENT_BINARY_DIR} 40 | ${CMAKE_CURRENT_SOURCE_DIR} 41 | ) 42 | target_link_libraries(${APPLIB_NAME} 43 | Qt5::Core 44 | Qt5::Widgets 45 | ) 46 | 47 | # Translations 48 | set(TS_FILES 49 | translations/${APP_NAME}_cs.ts 50 | translations/${APP_NAME}_da.ts 51 | translations/${APP_NAME}_de.ts 52 | translations/${APP_NAME}_en.ts 53 | translations/${APP_NAME}_es.ts 54 | translations/${APP_NAME}_fr.ts 55 | translations/${APP_NAME}_nl.ts 56 | translations/${APP_NAME}_no.ts 57 | translations/${APP_NAME}_pl.ts 58 | ) 59 | qt5_add_translation(QM_FILES ${TS_FILES}) 60 | add_custom_target(build_qm DEPENDS ${QM_FILES}) 61 | add_dependencies(${APPLIB_NAME} build_qm) 62 | 63 | add_lupdate_target(SOURCES ${APPLIB_SRCS} TS_FILES ${TS_FILES} OPTIONS -no-obsolete) 64 | 65 | # App executable 66 | set(APP_SRCS main.cpp) 67 | ecm_add_app_icon( 68 | APP_SRCS 69 | ICONS 70 | appicon/1024-apps-${APP_NAME}.png 71 | appicon/256-apps-${APP_NAME}.png 72 | appicon/sc-apps-${APP_NAME}.svg 73 | ) 74 | add_executable(${APP_NAME} WIN32 MACOSX_BUNDLE ${APP_SRCS}) 75 | 76 | if (APPLE) 77 | configure_file( 78 | ${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in 79 | ${CMAKE_CURRENT_BINARY_DIR}/macos/Info.plist 80 | ) 81 | set_target_properties(${APP_NAME} PROPERTIES 82 | MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/macos/Info.plist 83 | ) 84 | endif() 85 | 86 | target_link_libraries(${APP_NAME} 87 | ${APPLIB_NAME} 88 | singleapplication 89 | ) 90 | 91 | # Install 92 | install( 93 | TARGETS ${APP_NAME} 94 | BUNDLE DESTINATION . 95 | RUNTIME DESTINATION ${BIN_INSTALL_DIR} 96 | ) 97 | 98 | install( 99 | FILES ${QM_FILES} 100 | DESTINATION ${DATA_INSTALL_DIR}/translations 101 | ) 102 | 103 | if (UNIX AND NOT APPLE) 104 | install(FILES linux/${APP_NAME}.desktop 105 | DESTINATION share/applications 106 | RENAME ${INVERSE_ORGANIZATION_NAME}.${APP_NAME}.desktop 107 | ) 108 | install(FILES linux/${APP_NAME}.metainfo.xml 109 | DESTINATION share/metainfo 110 | RENAME ${INVERSE_ORGANIZATION_NAME}.${APP_NAME}.metainfo.xml 111 | ) 112 | endif() 113 | 114 | if (WIN32) 115 | include(DeployQt) 116 | windeployqt(${APP_NAME}) 117 | endif() 118 | 119 | if (APPLE) 120 | include(DeployQt) 121 | macdeployqt(${APP_NAME}) 122 | endif() 123 | -------------------------------------------------------------------------------- /src/TaskExtension.cpp: -------------------------------------------------------------------------------- 1 | #include "TaskExtension.h" 2 | 3 | #include 4 | #include 5 | 6 | #include "SyntaxHighlighter.h" 7 | 8 | static constexpr char INSERT_TASK_REGEX[] = "^(\\s*)([-\\*])?(\\s)?(\\[[x ]\\])?"; 9 | 10 | static int getTaskCheckmarkPosUnderCursor(const QTextCursor& cursor) { 11 | return SyntaxHighlighter::getTaskCheckmarkPosAt(cursor.block().text(), 12 | cursor.positionInBlock()); 13 | } 14 | 15 | static void toggleTask(QTextCursor* cursor, int pos) { 16 | // First insert the new character after the old one, then delete the old character. 17 | // This prevents the main cursor from moving as a side effect from the editing. 18 | cursor->beginEditBlock(); 19 | cursor->setPosition(pos + cursor->block().position()); 20 | cursor->movePosition(QTextCursor::Right, QTextCursor::KeepAnchor); 21 | QString text = cursor->selectedText(); 22 | cursor->movePosition(QTextCursor::Right); 23 | if (text == "x") { 24 | cursor->insertText(" "); 25 | } else { 26 | cursor->insertText("x"); 27 | } 28 | cursor->movePosition(QTextCursor::Left); 29 | cursor->deletePreviousChar(); 30 | cursor->endEditBlock(); 31 | } 32 | 33 | TaskExtension::TaskExtension(TextEdit* textEdit) 34 | : TextEditExtension(textEdit), mTaskAction(std::make_unique()) { 35 | mTaskAction->setText(tr("Insert/toggle task")); 36 | QList shortcuts = {Qt::CTRL | Qt::Key_Return, Qt::CTRL | Qt::Key_Enter}; 37 | mTaskAction->setShortcuts(shortcuts); 38 | connect(mTaskAction.get(), &QAction::triggered, this, &TaskExtension::insertOrToggleTask); 39 | mTextEdit->addAction(mTaskAction.get()); 40 | } 41 | 42 | void TaskExtension::aboutToShowEditContextMenu(QMenu* menu, const QPoint& /*pos*/) { 43 | menu->addAction(mTaskAction.get()); 44 | menu->addSeparator(); 45 | } 46 | 47 | bool TaskExtension::mouseRelease(QMouseEvent* event) { 48 | if (event->modifiers() == Qt::CTRL) { 49 | toggleTaskUnderCursor(); 50 | } 51 | return false; 52 | } 53 | 54 | void TaskExtension::insertOrToggleTask() { 55 | auto cursor = mTextEdit->textCursor(); 56 | QString text = cursor.block().text(); 57 | 58 | QRegularExpression expression(INSERT_TASK_REGEX); 59 | QRegularExpressionMatch match = expression.match(text); 60 | if (!match.hasMatch()) { 61 | // Should not happen 62 | return; 63 | } else if (!match.captured(3).isEmpty() && !match.captured(4).isEmpty()) { 64 | // Line already contains a task, toggle completion 65 | toggleTask(&cursor, match.capturedStart(4) + 1); 66 | return; 67 | } else if (match.captured(2).isEmpty()) { 68 | // Not in list 69 | cursor.beginEditBlock(); 70 | cursor.setPosition(cursor.block().position() + match.capturedEnd(1)); 71 | cursor.insertText("- [ ] "); 72 | cursor.endEditBlock(); 73 | } else { 74 | // In list, only add checkbox 75 | bool trailingSpace = !match.captured(3).isEmpty(); 76 | cursor.beginEditBlock(); 77 | cursor.setPosition(cursor.block().position() + match.capturedEnd(2)); 78 | cursor.insertText(trailingSpace ? " [ ]" : " [ ] "); 79 | cursor.endEditBlock(); 80 | } 81 | } 82 | 83 | void TaskExtension::toggleTaskUnderCursor() { 84 | auto cursor = mTextEdit->textCursor(); 85 | int pos = getTaskCheckmarkPosUnderCursor(cursor); 86 | if (pos == -1) { 87 | return; 88 | } 89 | toggleTask(&cursor, pos); 90 | } 91 | -------------------------------------------------------------------------------- /src/TextEdit.cpp: -------------------------------------------------------------------------------- 1 | #include "TextEdit.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "SyntaxHighlighter.h" 10 | 11 | // TextEditExtension --------------------- 12 | TextEditExtension::TextEditExtension(TextEdit* textEdit) : QObject(textEdit), mTextEdit(textEdit) { 13 | } 14 | 15 | void TextEditExtension::aboutToShowContextMenu(QMenu* /*menu*/, const QPoint& /*pos*/) { 16 | } 17 | 18 | void TextEditExtension::aboutToShowEditContextMenu(QMenu* /*menu*/, const QPoint& /*pos*/) { 19 | } 20 | 21 | void TextEditExtension::aboutToShowViewContextMenu(QMenu* /*menu*/, const QPoint& /*pos*/) { 22 | } 23 | 24 | bool TextEditExtension::keyPress(QKeyEvent* /*event*/) { 25 | return false; 26 | } 27 | 28 | bool TextEditExtension::keyRelease(QKeyEvent* /*event*/) { 29 | return false; 30 | } 31 | 32 | bool TextEditExtension::mouseRelease(QMouseEvent* /*event*/) { 33 | return false; 34 | } 35 | 36 | bool TextEditExtension::mouseMove(QMouseEvent* /*event*/) { 37 | return false; 38 | } 39 | 40 | bool TextEditExtension::wheel(QWheelEvent* /*event*/) { 41 | return false; 42 | } 43 | 44 | // TextEdit ------------------------------ 45 | TextEdit::TextEdit(QWidget* parent) : QPlainTextEdit(parent) { 46 | new SyntaxHighlighter(document()); 47 | } 48 | 49 | void TextEdit::contextMenuEvent(QContextMenuEvent* event) { 50 | auto pos = event->pos(); 51 | std::unique_ptr menu(createStandardContextMenu(pos)); 52 | menu->addSeparator(); 53 | auto editMenu = menu->addMenu(tr("Edit")); 54 | connect(editMenu, &QMenu::aboutToShow, this, [this, editMenu, pos] { 55 | for (auto extension : mExtensions) { 56 | extension->aboutToShowEditContextMenu(editMenu, pos); 57 | } 58 | }); 59 | auto viewMenu = menu->addMenu(tr("View")); 60 | connect(viewMenu, &QMenu::aboutToShow, this, [this, viewMenu, pos] { 61 | for (auto extension : mExtensions) { 62 | extension->aboutToShowViewContextMenu(viewMenu, pos); 63 | } 64 | }); 65 | for (auto extension : mExtensions) { 66 | extension->aboutToShowContextMenu(menu.get(), pos); 67 | } 68 | menu->exec(event->globalPos()); 69 | } 70 | 71 | void TextEdit::keyPressEvent(QKeyEvent* event) { 72 | for (auto extension : mExtensions) { 73 | if (extension->keyPress(event)) { 74 | return; 75 | } 76 | } 77 | QPlainTextEdit::keyPressEvent(event); 78 | } 79 | 80 | void TextEdit::keyReleaseEvent(QKeyEvent* event) { 81 | for (auto extension : mExtensions) { 82 | if (extension->keyRelease(event)) { 83 | return; 84 | } 85 | } 86 | QPlainTextEdit::keyReleaseEvent(event); 87 | } 88 | 89 | void TextEdit::mouseReleaseEvent(QMouseEvent* event) { 90 | for (auto extension : mExtensions) { 91 | if (extension->mouseRelease(event)) { 92 | return; 93 | } 94 | } 95 | QPlainTextEdit::mouseReleaseEvent(event); 96 | } 97 | 98 | void TextEdit::mouseMoveEvent(QMouseEvent* event) { 99 | for (auto extension : mExtensions) { 100 | if (extension->mouseMove(event)) { 101 | return; 102 | } 103 | } 104 | QPlainTextEdit::mouseMoveEvent(event); 105 | } 106 | 107 | void TextEdit::wheelEvent(QWheelEvent* event) { 108 | for (auto extension : mExtensions) { 109 | if (extension->wheel(event)) { 110 | return; 111 | } 112 | } 113 | QPlainTextEdit::wheelEvent(event); 114 | } 115 | 116 | void TextEdit::addExtension(TextEditExtension* extension) { 117 | mExtensions << extension; 118 | } 119 | -------------------------------------------------------------------------------- /cmake/DeployQt.cmake: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2018 Nathan Osman 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | find_package(Qt5Core REQUIRED) 24 | 25 | # Retrieve the absolute path to qmake and then use that path to find 26 | # the windeployqt and macdeployqt binaries 27 | get_target_property(_qmake_executable Qt5::qmake IMPORTED_LOCATION) 28 | get_filename_component(_qt_bin_dir "${_qmake_executable}" DIRECTORY) 29 | 30 | find_program(WINDEPLOYQT_EXECUTABLE windeployqt HINTS "${_qt_bin_dir}") 31 | if(WIN32 AND NOT WINDEPLOYQT_EXECUTABLE) 32 | message(FATAL_ERROR "windeployqt not found") 33 | endif() 34 | 35 | find_program(MACDEPLOYQT_EXECUTABLE macdeployqt HINTS "${_qt_bin_dir}") 36 | if(APPLE AND NOT MACDEPLOYQT_EXECUTABLE) 37 | message(FATAL_ERROR "macdeployqt not found") 38 | endif() 39 | 40 | # Add commands that copy the required Qt files to the same directory as the 41 | # target after being built as well as including them in final installation 42 | function(windeployqt target) 43 | 44 | # Run windeployqt immediately after build 45 | # Deploy all files in a deployqt sub directory so that we can copy it with 46 | # install() below 47 | # Define the plugindir otherwise plugins directories are copied directly in 48 | # deployqt// instead of in deployqt/plugins// 49 | # and that causes QIcon to fail to load svg icons (at least with Qt 5.12.8) 50 | add_custom_command(TARGET ${target} POST_BUILD 51 | COMMAND "${WINDEPLOYQT_EXECUTABLE}" 52 | --no-angle 53 | --no-opengl-sw 54 | \"$\" 55 | --dir ${PROJECT_BINARY_DIR}/deployqt 56 | --plugindir ${PROJECT_BINARY_DIR}/deployqt/plugins 57 | COMMENT "Deploying Qt..." 58 | ) 59 | 60 | # Install the deployed files so CPack pick them up 61 | # Use "deployqt/" with a trailing slash so that CMake does not create a 62 | # "deployqt" directory in our destination directory 63 | install( 64 | DIRECTORY ${PROJECT_BINARY_DIR}/deployqt/ 65 | DESTINATION ${BIN_INSTALL_DIR} 66 | ) 67 | endfunction() 68 | 69 | # Add commands that copy the required Qt files to the application bundle 70 | # represented by the target. 71 | function(macdeployqt target) 72 | execute_process( 73 | COMMAND ${_qmake_executable} -query QT_INSTALL_TRANSLATIONS 74 | OUTPUT_VARIABLE _qt_translations_dir 75 | OUTPUT_STRIP_TRAILING_WHITESPACE 76 | ) 77 | 78 | add_custom_command(TARGET ${target} POST_BUILD 79 | COMMAND "${MACDEPLOYQT_EXECUTABLE}" 80 | \"$/../..\" 81 | COMMENT "Deploying Qt..." 82 | ) 83 | install( 84 | DIRECTORY ${_qt_translations_dir}/ 85 | DESTINATION $/../Translations 86 | FILES_MATCHING PATTERN "qtbase_*.qm" 87 | ) 88 | endfunction() 89 | 90 | mark_as_advanced(WINDEPLOYQT_EXECUTABLE MACDEPLOYQT_EXECUTABLE) 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.4.1 - 2023-12-01 4 | 5 | ### Added 6 | 7 | - Nanonote now speaks Danish (Morgenkaff) 8 | - Nanonote now speaks Dutch (Heimen Stoffels) 9 | - Nanonote now speaks Polish (Marek Szumny) 10 | - Nanonote now speaks Norwegian (Vidar Karlsen) 11 | 12 | ## 1.4.0 - 2023-04-11 13 | 14 | ### Added 15 | 16 | - Add support for Markdown-style tasks in lists (Daniel Laidig) 17 | - Add tips page (Aurelien Gateau) 18 | - Nanonote now highlights Markdown-like headings (Aurelien Gateau) 19 | - Nanonote now speaks Czech (Amerey) 20 | 21 | ### Changed 22 | 23 | - Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig) 24 | 25 | ### Fixed 26 | 27 | - Make sure standard actions like Copy or Paste are translated (Aurelien Gateau) 28 | - Show keyboard shortcuts in context menus on macOS (Daniel Laidig) 29 | - Do not change cursor to pointing-hand when not over a link (Aurelien Gateau) 30 | 31 | ## 1.3.93 - 2023-04-03 32 | 33 | ### Fixed 34 | 35 | - Fixed a typo in the Appstream ID, which made creating a Flatpak for the app complicated. 36 | 37 | ## 1.3.92 - 2023-04-02 38 | 39 | ### Added 40 | 41 | - Nanonote now highlights Markdown-like headings. 42 | 43 | ## 1.3.91 - 2023-03-12 44 | 45 | ### Added 46 | 47 | - Add support for Markdown-style tasks in lists (Daniel Laidig) 48 | - Add tips page (Aurelien Gateau) 49 | 50 | ### Changed 51 | 52 | - Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig) 53 | 54 | ### Fixed 55 | 56 | - Make sure standard actions like Copy or Paste are translated (Aurelien Gateau) 57 | - Show keyboard shortcuts in context menus on macOS (Daniel Laidig) 58 | - Do not change cursor to pointing-hand when not over a link (Aurelien Gateau) 59 | 60 | ### Internals 61 | 62 | - CI: Bump Ubuntu to 20.04 and macOS to 11 (Aurelien Gateau) 63 | - CI: Install clang-format from muttleyxd/clang-tools-static-binaries (Aurelien Gateau) 64 | - Bump Qt to 5.15.2 on macOS and Windows (Aurelien Gateau) 65 | - Update singleaplication to 3.3.4 (Aurelien Gateau) 66 | - Update Catch2 to 3.3.0 (Aurelien Gateau) 67 | 68 | ## 1.3.0 - 2020-10-03 69 | 70 | ### Changed 71 | 72 | - Update Spanish translation (Victorhck) 73 | 74 | ### Fixed 75 | 76 | - Properly encode URL of the note path (Aurelien Gateau) 77 | - Fix untranslated text in About tab on Linux (Aurelien Gateau) 78 | 79 | ## 1.2.91 - 2020-09-28 80 | 81 | ### Added 82 | 83 | - You can now search inside your notes with the new search bar (Pavol Oresky) 84 | - You can now move selected lines up and down with Alt+Shift+Up and Down (Aurelien Gateau) 85 | - macOS dmg (Aurelien Gateau) 86 | - Windows installer (Aurelien Gateau) 87 | 88 | ### Changed 89 | 90 | - Reorganized context menu: added "Edit" and "View" submenus (Aurelien Gateau) 91 | 92 | ## 1.2.0 - 2019-05-11 93 | 94 | ### Added 95 | 96 | - New German translation by Vinzenz Vietzke 97 | - Allow changing the font size using Ctrl + mouse wheel (Daniel Laidig) 98 | - Use the link color of the color theme instead of an hardcoded blue (Daniel Laidig) 99 | - Added a way to reset the font size to the default value (Daniel Laidig) 100 | 101 | ### Fixed 102 | 103 | - Added explanation of how to open URLs to the welcome text (Robert Barat) 104 | - Allow '@' in URLs (Aurelien Gateau) 105 | - Use QSaveFile for safer saving (Aurelien Gateau) 106 | 107 | ## 1.1.0 - 2019-02-04 108 | 109 | ### Added 110 | 111 | - Pressing tab now indents the whole line when the cursor is at the beginning of a list item (Daniel Laidig). 112 | - Pressing Enter on an empty list item now unindents, then removes the bullet (Aurelien Gateau). 113 | - Added French and Spanish translations (Aurelien Gateau, Victorhck). 114 | 115 | ### Fixed 116 | 117 | - Improved url detection: '+', '%' and '~' are now allowed in the middle of urls (Aurelien Gateau). 118 | - Fixed wrong indentation behavior in upward selections (Aurelien Gateau). 119 | 120 | ## 1.0.1 - 2019-01-12 121 | 122 | ### Added 123 | 124 | - Added unit-tests. 125 | - Added Travis integration. 126 | - Added rpm and deb packages generated using CPack. 127 | 128 | ### Fixed 129 | 130 | - Fixed indentation and make it respect indentation columns. 131 | - Made it possible to indent/unindent selected lines with Tab/Shift+Tab. 132 | - Update welcome text to reflect current shortcuts. 133 | 134 | ## 1.0.0 - 2018-12-30 135 | 136 | ### Added 137 | 138 | - First release 139 | -------------------------------------------------------------------------------- /src/MoveLinesExtension.cpp: -------------------------------------------------------------------------------- 1 | #include "MoveLinesExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | MoveLinesExtension::MoveLinesExtension(TextEdit* textEdit) 9 | : TextEditExtension(textEdit) 10 | , mMoveUpAction(std::make_unique()) 11 | , mMoveDownAction(std::make_unique()) { 12 | mMoveUpAction->setText(tr("Move selected lines up")); 13 | mMoveUpAction->setShortcut(Qt::SHIFT | Qt::ALT | Qt::Key_Up); 14 | connect(mMoveUpAction.get(), &QAction::triggered, this, &MoveLinesExtension::moveUp); 15 | mTextEdit->addAction(mMoveUpAction.get()); 16 | 17 | mMoveDownAction->setText(tr("Move selected lines down")); 18 | mMoveDownAction->setShortcut(Qt::SHIFT | Qt::ALT | Qt::Key_Down); 19 | connect(mMoveDownAction.get(), &QAction::triggered, this, &MoveLinesExtension::moveDown); 20 | mTextEdit->addAction(mMoveDownAction.get()); 21 | } 22 | 23 | MoveLinesExtension::~MoveLinesExtension() { 24 | } 25 | 26 | void MoveLinesExtension::aboutToShowEditContextMenu(QMenu* menu, const QPoint& /*pos*/) { 27 | menu->addAction(mMoveUpAction.get()); 28 | menu->addAction(mMoveDownAction.get()); 29 | } 30 | 31 | void MoveLinesExtension::moveUp() { 32 | moveSelectedLines(-1); 33 | } 34 | 35 | void MoveLinesExtension::moveDown() { 36 | moveSelectedLines(1); 37 | } 38 | 39 | /** 40 | * Add a final \\n if needed. Returns true if it added one. 41 | */ 42 | static bool addFinalNewLine(TextEdit* textEdit, QTextCursor* cursor) { 43 | if (textEdit->document()->lastBlock().text().isEmpty()) { 44 | return false; 45 | } 46 | 47 | // The `cursor` from `moveSelectedLines()` must stay at the same position. A previous version of 48 | // this function created its own cursor using QTextEdit::textCursor(), but if the cursor is at 49 | // the very end of the document, then `moveSelectedLines()` cursor is moved when we insert the 50 | // \n. I assue this is because the cursor is considered to be *after* the insertion position 51 | // of the \n, so Qt maintains its position. 52 | // To avoid that, we save the cursor state manually before inserting the \n, and restore the 53 | // state before leaving this function. 54 | int oldStart = cursor->selectionStart(); 55 | int oldEnd = cursor->selectionEnd(); 56 | if (oldStart == cursor->position()) { 57 | std::swap(oldStart, oldEnd); 58 | } 59 | 60 | cursor->movePosition(QTextCursor::End); 61 | cursor->insertBlock(); 62 | 63 | cursor->setPosition(oldStart); 64 | cursor->setPosition(oldEnd, QTextCursor::KeepAnchor); 65 | return true; 66 | } 67 | 68 | static void removeFinalNewLine(QTextCursor* editCursor) { 69 | editCursor->movePosition(QTextCursor::End); 70 | editCursor->deletePreviousChar(); 71 | } 72 | 73 | void MoveLinesExtension::moveSelectedLines(int delta) { 74 | auto doc = mTextEdit->document(); 75 | QTextCursor cursor = mTextEdit->textCursor(); 76 | 77 | cursor.beginEditBlock(); 78 | 79 | // To avoid dealing with the special-case of the last line not ending with 80 | // a \n, we add one if there is none and remove it at the end 81 | bool addedFinalNewLine = addFinalNewLine(mTextEdit, &cursor); 82 | 83 | // Find start and end of lines to move 84 | QTextBlock startBlock, endBlock; 85 | if (cursor.hasSelection()) { 86 | int end = cursor.selectionEnd(); 87 | startBlock = doc->findBlock(cursor.selectionStart()); 88 | endBlock = doc->findBlock(end); 89 | // If the end is not at the start of the block, select this block too 90 | if (end - endBlock.position() > 0) { 91 | endBlock = endBlock.next(); 92 | } 93 | } else { 94 | startBlock = cursor.block(); 95 | endBlock = startBlock.next(); 96 | } 97 | auto startInsideBlock = cursor.selectionStart() - startBlock.position(); 98 | auto endInsideBlock = cursor.selectionEnd() - startBlock.position(); 99 | bool cursorAtStart = cursor.position() == cursor.selectionStart(); 100 | if (cursorAtStart) { 101 | std::swap(startInsideBlock, endInsideBlock); 102 | } 103 | 104 | // Cut the lines to move 105 | cursor.setPosition(startBlock.position()); 106 | cursor.setPosition(endBlock.position(), QTextCursor::KeepAnchor); 107 | auto textToMove = cursor.selectedText(); 108 | cursor.removeSelectedText(); 109 | 110 | // Move the cursor to the right position and paste the lines 111 | cursor.movePosition(delta < 0 ? QTextCursor::PreviousBlock : QTextCursor::NextBlock); 112 | int position = cursor.position(); 113 | cursor.insertText(textToMove); 114 | 115 | // Restore selection 116 | cursor.setPosition(position + startInsideBlock); 117 | cursor.setPosition(position + endInsideBlock, QTextCursor::KeepAnchor); 118 | mTextEdit->setTextCursor(cursor); 119 | 120 | if (addedFinalNewLine) { 121 | removeFinalNewLine(&cursor); 122 | } 123 | 124 | cursor.endEditBlock(); 125 | } 126 | -------------------------------------------------------------------------------- /tests/SearchWidgetTest.cpp: -------------------------------------------------------------------------------- 1 | #include "SearchWidget.h" 2 | #include "TextEdit.h" 3 | 4 | #include "TextUtils.h" 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | #include 11 | 12 | struct CursorSpan { 13 | CursorSpan(const QTextCursor& cursor) 14 | : start(cursor.selectionStart()), length(cursor.selectionEnd() - start) { 15 | } 16 | CursorSpan(int start, int length) : start(start), length(length) { 17 | } 18 | 19 | const int start; 20 | const int length; 21 | }; 22 | 23 | bool operator==(const CursorSpan& c1, const CursorSpan& c2) { 24 | return c1.start == c2.start && c1.length == c2.length; 25 | } 26 | 27 | TEST_CASE("searchwidget") { 28 | TextEdit edit; 29 | SearchWidget searchWidget(&edit); 30 | auto* nextButton = searchWidget.findChild("nextButton"); 31 | REQUIRE(nextButton); 32 | auto* previousButton = searchWidget.findChild("previousButton"); 33 | REQUIRE(nextButton); 34 | 35 | SECTION("forward search") { 36 | edit.setPlainText("a b b b"); 37 | searchWidget.initialize("b"); 38 | REQUIRE(dumpTextEditContent(&edit) == "a *b| b b"); 39 | SECTION("search again") { 40 | QTest::mouseClick(nextButton, Qt::LeftButton); 41 | REQUIRE(dumpTextEditContent(&edit) == "a b *b| b"); 42 | 43 | QTest::mouseClick(nextButton, Qt::LeftButton); 44 | REQUIRE(dumpTextEditContent(&edit) == "a b b *b|"); 45 | 46 | SECTION("wrap around") { 47 | QTest::mouseClick(nextButton, Qt::LeftButton); 48 | REQUIRE(dumpTextEditContent(&edit) == "a *b| b b"); 49 | } 50 | } 51 | } 52 | 53 | SECTION("backward search") { 54 | edit.setPlainText("a b b b"); 55 | searchWidget.initialize("b"); 56 | REQUIRE(dumpTextEditContent(&edit) == "a *b| b b"); 57 | 58 | QTest::mouseClick(previousButton, Qt::LeftButton); 59 | REQUIRE(dumpTextEditContent(&edit) == "a b b *b|"); 60 | 61 | QTest::mouseClick(previousButton, Qt::LeftButton); 62 | REQUIRE(dumpTextEditContent(&edit) == "a b *b| b"); 63 | 64 | QTest::mouseClick(previousButton, Qt::LeftButton); 65 | REQUIRE(dumpTextEditContent(&edit) == "a *b| b b"); 66 | } 67 | 68 | SECTION("no hit") { 69 | setupTextEditContent(&edit, "h|ello"); 70 | 71 | searchWidget.initialize("b"); 72 | REQUIRE(dumpTextEditContent(&edit) == "h|ello"); 73 | } 74 | 75 | SECTION("count label") { 76 | edit.setPlainText("hello"); 77 | auto* countLabel = searchWidget.findChild("countLabel"); 78 | auto isVisible = [countLabel, &searchWidget]() -> bool { 79 | return countLabel->isVisibleTo(&searchWidget); 80 | }; 81 | 82 | searchWidget.initialize(""); 83 | REQUIRE(!isVisible()); 84 | 85 | searchWidget.initialize("llo"); 86 | REQUIRE(isVisible()); 87 | 88 | searchWidget.initialize("no match"); 89 | REQUIRE(!isVisible()); 90 | } 91 | 92 | SECTION("highlights") { 93 | edit.setPlainText("a bb bb"); 94 | searchWidget.initialize("bb"); 95 | auto selections = edit.extraSelections(); 96 | CHECK(selections.count() == 2); 97 | CHECK(CursorSpan(selections.at(0).cursor) == CursorSpan{2, 2}); 98 | CHECK(CursorSpan(selections.at(1).cursor) == CursorSpan{5, 2}); 99 | 100 | SECTION("uninitialize must remove highlights") { 101 | searchWidget.uninitialize(); 102 | selections = edit.extraSelections(); 103 | REQUIRE(selections.count() == 0); 104 | 105 | SECTION("initializing again with the same text must bring back highlights") { 106 | searchWidget.initialize("bb"); 107 | selections = edit.extraSelections(); 108 | CHECK(selections.count() == 2); 109 | CHECK(CursorSpan(selections.at(0).cursor) == CursorSpan{2, 2}); 110 | CHECK(CursorSpan(selections.at(1).cursor) == CursorSpan{5, 2}); 111 | } 112 | } 113 | } 114 | 115 | SECTION("search, change selection") { 116 | // GIVEN a search with multiple matches 117 | edit.setPlainText("a bb bb cc bb"); 118 | searchWidget.initialize("bb"); 119 | REQUIRE(dumpTextEditContent(&edit) == "a *bb| bb cc bb"); 120 | 121 | // AND cursor has been moved after the 2nd match 122 | auto cursor = edit.textCursor(); 123 | cursor.setPosition(cursor.position() + 4); 124 | edit.setTextCursor(cursor); 125 | REQUIRE(dumpTextEditContent(&edit) == "a bb bb |cc bb"); 126 | 127 | SECTION("then search forward") { 128 | // WHEN I search for the next match 129 | QTest::mouseClick(nextButton, Qt::LeftButton); 130 | 131 | // THEN the first match *after the cursor* is selected 132 | REQUIRE(dumpTextEditContent(&edit) == "a bb bb cc *bb|"); 133 | } 134 | SECTION("then search backward") { 135 | // WHEN I search for the previous match 136 | QTest::mouseClick(previousButton, Qt::LeftButton); 137 | 138 | // THEN the first match *before the cursor* is selected 139 | REQUIRE(dumpTextEditContent(&edit) == "a bb *bb| cc bb"); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/IndentExtensionTest.cpp: -------------------------------------------------------------------------------- 1 | #include "IndentExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "Catch2QtUtils.h" 10 | #include "TextUtils.h" 11 | 12 | TEST_CASE("textedit") { 13 | QMainWindow window; 14 | TextEdit* edit = new TextEdit; 15 | window.setCentralWidget(edit); 16 | edit->addExtension(new IndentExtension(edit)); 17 | SECTION("indent insert spaces") { 18 | edit->setPlainText("Hello"); 19 | 20 | SECTION("indent from beginning of line") { 21 | QTest::keyClick(edit, Qt::Key_Tab); 22 | REQUIRE(edit->toPlainText() == QString(" Hello")); 23 | } 24 | 25 | SECTION("indent from middle of word") { 26 | QTest::keyClick(edit, Qt::Key_Right); 27 | QTest::keyClick(edit, Qt::Key_Tab); 28 | REQUIRE(edit->toPlainText() == QString("H ello")); 29 | } 30 | } 31 | 32 | SECTION("indent whole lines") { 33 | setupTextEditContent(edit, 34 | "*1\n" 35 | "2\n" 36 | "|3\n"); 37 | auto cursor = edit->textCursor(); 38 | QTest::keyClick(edit, Qt::Key_Tab); 39 | REQUIRE(dumpTextEditContent(edit) 40 | == QString(" *1\n" 41 | " 2\n" 42 | "|3\n")); 43 | } 44 | 45 | SECTION("unindent whole lines") { 46 | setupTextEditContent(edit, 47 | "* 1\n" 48 | " 2\n" 49 | "|3\n"); 50 | QTest::keyClick(edit, Qt::Key_Backtab); 51 | REQUIRE(dumpTextEditContent(edit) 52 | == QString("*1\n" 53 | "2\n" 54 | "|3\n")); 55 | } 56 | 57 | // https://github.com/agateau/nanonote/issues/6 58 | SECTION("indent upward selection") { 59 | setupTextEditContent(edit, 60 | "|a\n" 61 | "*b\n"); 62 | // Indent twice, only the first line should be indented 63 | QTest::keyClick(edit, Qt::Key_Tab); 64 | QTest::keyClick(edit, Qt::Key_Tab); 65 | REQUIRE(dumpTextEditContent(edit) 66 | == QString(" |a\n" 67 | "*b\n")); 68 | } 69 | 70 | SECTION("indent partially selected lines") { 71 | setupTextEditContent(edit, 72 | "*aa\n" 73 | "b|b\n"); 74 | // Indent, both lines should be indented 75 | QTest::keyClick(edit, Qt::Key_Tab); 76 | REQUIRE(dumpTextEditContent(edit) 77 | == QString(" *aa\n" 78 | " b|b\n")); 79 | } 80 | 81 | SECTION("indent upward partially selected lines") { 82 | setupTextEditContent(edit, 83 | "a|a\n" 84 | "b*b\n"); 85 | // Indent, both lines should be indented 86 | QTest::keyClick(edit, Qt::Key_Tab); 87 | REQUIRE(dumpTextEditContent(edit) 88 | == QString(" a|a\n" 89 | " b*b\n")); 90 | } 91 | 92 | SECTION("indent at start of unindented list") { 93 | setupTextEditContent(edit, 94 | "- item\n" 95 | "- |\n"); 96 | QTest::keyClick(edit, Qt::Key_Tab); 97 | REQUIRE(dumpTextEditContent(edit) 98 | == QString("- item\n" 99 | " - |\n")); 100 | } 101 | 102 | SECTION("indent at start of unindented list, no trailing newline") { 103 | setupTextEditContent(edit, 104 | "- item\n" 105 | "- |"); 106 | edit->moveCursor(QTextCursor::Down); 107 | edit->moveCursor(QTextCursor::Right); 108 | edit->moveCursor(QTextCursor::Right); 109 | QTest::keyClick(edit, Qt::Key_Tab); 110 | REQUIRE(dumpTextEditContent(edit) 111 | == QString("- item\n" 112 | " - |")); 113 | } 114 | 115 | SECTION("indent at start of indented list") { 116 | setupTextEditContent(edit, 117 | " - item\n" 118 | " - |"); 119 | QTest::keyClick(edit, Qt::Key_Tab); 120 | REQUIRE(dumpTextEditContent(edit) 121 | == QString(" - item\n" 122 | " - |")); 123 | } 124 | 125 | SECTION("Return on empty bullet line removes the bullet") { 126 | setupTextEditContent(edit, "- |"); 127 | QTest::keyClick(edit, Qt::Key_Return); 128 | REQUIRE(edit->toPlainText() == QString()); 129 | } 130 | 131 | SECTION("Return on empty indented bullet line unindents") { 132 | setupTextEditContent(edit, " - |"); 133 | QTest::keyClick(edit, Qt::Key_Return); 134 | REQUIRE(dumpTextEditContent(edit) == QString("- |")); 135 | } 136 | 137 | SECTION("Return on empty line inserts a new line") { 138 | setupTextEditContent(edit, "|"); 139 | QTest::keyClick(edit, Qt::Key_Return); 140 | REQUIRE(dumpTextEditContent(edit) == QString("\n|")); 141 | } 142 | 143 | SECTION("Return on selected text replaces it with a new line") { 144 | setupTextEditContent(edit, 145 | "*a\n" 146 | "b|c"); 147 | QTest::keyClick(edit, Qt::Key_Return); 148 | REQUIRE(dumpTextEditContent(edit) 149 | == QString("\n" 150 | "|c")); 151 | } 152 | 153 | SECTION("Return on indented bullet line inserts new bullet") { 154 | setupTextEditContent(edit, " - item|"); 155 | QTest::keyClick(edit, Qt::Key_Return); 156 | REQUIRE(dumpTextEditContent(edit) 157 | == QString(" - item\n" 158 | " - |")); 159 | } 160 | 161 | SECTION("Return on indented checked task line inserts new unchecked task") { 162 | setupTextEditContent(edit, " - [x] item|"); 163 | QTest::keyClick(edit, Qt::Key_Return); 164 | REQUIRE(dumpTextEditContent(edit) 165 | == QString(" - [x] item\n" 166 | " - [ ] |")); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import sys 5 | import xml.etree.ElementTree as ET 6 | from pathlib import Path 7 | from tempfile import NamedTemporaryFile 8 | 9 | from invoke import run, task 10 | 11 | from changelog import Changelog, Release 12 | 13 | ARTIFACTS_DIR = Path("artifacts") 14 | 15 | CHANGELOG_MD = Path("CHANGELOG.md") 16 | APPSTREAM_XML = Path("src/linux/nanonote.metainfo.xml") 17 | 18 | MAIN_BRANCH = "master" 19 | 20 | PREP_RELEASE_BRANCH = "prep-release" 21 | 22 | 23 | def get_version(): 24 | return os.environ["VERSION"] 25 | 26 | 27 | def erun(*args, **kwargs): 28 | """Like run, but with echo on""" 29 | kwargs["echo"] = True 30 | return run(*args, **kwargs) 31 | 32 | 33 | def cerun(c, *args, **kwargs): 34 | """Like Context.run, but with echo on""" 35 | kwargs["echo"] = True 36 | return c.run(*args, **kwargs) 37 | 38 | 39 | def ask(msg: str) -> str: 40 | """Show a message, wait for input and returns it""" 41 | print(msg, end=" ") 42 | return input() 43 | 44 | 45 | def is_ok(msg: str) -> bool: 46 | """Show a message, append (y/n) and return True if user select y or Y""" 47 | answer = ask(f"{msg} (y/n)").lower() 48 | return answer == "y" 49 | 50 | 51 | @task(help={"skip_changelog": "Add skip-changelog label"}) 52 | def create_pr(c, skip_changelog=False): 53 | """Create a pull-request and mark it as auto-mergeable""" 54 | cmd = "gh pr create --fill" 55 | if skip_changelog: 56 | cmd += " --label skip-changelog" 57 | result = cerun(c, cmd, warn=True) 58 | if not result: 59 | sys.exit(1) 60 | cerun(c, "gh pr merge --auto -dm") 61 | 62 | 63 | @task 64 | def update_version(c): 65 | version = get_version() 66 | path = Path("CMakeLists.txt") 67 | text = path.read_text() 68 | text, count = re.subn( 69 | r"^ VERSION .*", f" VERSION {version}", text, flags=re.MULTILINE 70 | ) 71 | assert count == 0 or count == 1 72 | path.write_text(text) 73 | 74 | 75 | @task 76 | def update_appstream_releases(c): 77 | """Regenerate the element of our appstream file from 78 | CHANGELOG.md""" 79 | changelog = Changelog.from_path(CHANGELOG_MD) 80 | 81 | releases_et = ET.Element("releases") 82 | for release in changelog.releases.values(): 83 | release_et = ET.SubElement( 84 | releases_et, "release", {"version": release.version, "date": release.date} 85 | ) 86 | description_et = ET.SubElement(release_et, "description") 87 | for change_type, changes in release.changes.items(): 88 | p_et = ET.SubElement(description_et, "p") 89 | p_et.text = change_type 90 | ul_et = ET.SubElement(description_et, "ul") 91 | for change in changes: 92 | li_et = ET.SubElement(ul_et, "li") 93 | li_et.text = change 94 | content = ET.tostring(releases_et, encoding="unicode") 95 | 96 | # Replace the element by hand to avoid loosing comments, if any 97 | appstream_content = APPSTREAM_XML.read_text() 98 | appstream_content, count = re.subn( 99 | ".*", content, appstream_content, flags=re.DOTALL 100 | ) 101 | assert count == 1 102 | subprocess.run( 103 | ["xmllint", "--format", "--output", APPSTREAM_XML, "-"], 104 | check=True, 105 | text=True, 106 | input=appstream_content, 107 | ) 108 | 109 | 110 | @task 111 | def create_release_branch(c): 112 | version = get_version() 113 | run(f"gh issue list -m {version}", pty=True) 114 | run("gh pr list", pty=True) 115 | if not is_ok("Continue?"): 116 | sys.exit(1) 117 | 118 | erun(f"git checkout {MAIN_BRANCH}") 119 | erun("git pull") 120 | erun("git status -s") 121 | if not is_ok("Continue?"): 122 | sys.exit(1) 123 | 124 | create_release_branch2(c) 125 | 126 | 127 | @task 128 | def create_release_branch2(c): 129 | version = get_version() 130 | erun(f"git checkout -b {PREP_RELEASE_BRANCH}") 131 | 132 | update_version(c) 133 | 134 | erun(f"changie batch {version}") 135 | print(f"Review/edit changelog (.changes/{version}.md)") 136 | if not is_ok("Looks good?"): 137 | sys.exit(1) 138 | erun("changie merge") 139 | print("Review CHANGELOG.md") 140 | 141 | if not is_ok("Looks good?"): 142 | sys.exit(1) 143 | 144 | update_appstream_releases(c) 145 | 146 | 147 | @task 148 | def update_ts(c): 149 | erun("ninja -C build lupdate") 150 | erun("git add src/translations") 151 | erun("git commit -m 'Update translations'") 152 | 153 | 154 | @task 155 | def commit_push(c): 156 | version = get_version() 157 | erun("git add .") 158 | erun(f'git commit -m "Prepare release of {version}"') 159 | erun(f"git push -u origin {PREP_RELEASE_BRANCH}") 160 | create_pr(c) 161 | 162 | 163 | @task 164 | def tag(c): 165 | version = get_version() 166 | erun(f"git checkout {MAIN_BRANCH}") 167 | erun("git pull") 168 | changes_file = Path(".changes") / f"{version}.md" 169 | if not changes_file.exists(): 170 | print(f"{changes_file} does not exist, check previous PR has been merged") 171 | sys.exit(1) 172 | if not is_ok("Create tag?"): 173 | sys.exit(1) 174 | 175 | erun(f"git tag -a {version} -m 'Releasing version {version}'") 176 | 177 | erun(f"git push origin {version}") 178 | 179 | 180 | def prepare_release_notes(release: Release) -> str: 181 | """ 182 | Take a Release instance and produce markdown suitable for GitHub release 183 | notes 184 | """ 185 | lines = [] 186 | for change_type, changes in release.changes.items(): 187 | lines.append(f"## {change_type}") 188 | for change in changes: 189 | lines.append(f"- {change}") 190 | return "\n\n".join(lines) + "\n" 191 | 192 | 193 | @task(help={"pre": "This is a prerelease"}) 194 | def publish(c, pre=False): 195 | version = get_version() 196 | changelog = Changelog.from_path(CHANGELOG_MD) 197 | release = changelog.releases[version] 198 | content = prepare_release_notes(release) 199 | 200 | with NamedTemporaryFile() as tmp_file: 201 | tmp_file.write(content.encode("utf-8")) 202 | tmp_file.flush() 203 | cmd = f"gh release edit {version} -F{tmp_file.name} --draft=false" 204 | if pre: 205 | cmd += " --prerelease" 206 | erun(cmd) 207 | -------------------------------------------------------------------------------- /src/SearchWidget.cpp: -------------------------------------------------------------------------------- 1 | #include "SearchWidget.h" 2 | #include "ui_SearchWidget.h" 3 | 4 | #include 5 | 6 | #include "TextEdit.h" 7 | 8 | SearchWidget::SearchWidget(TextEdit* textEdit, QWidget* parent) 9 | : QWidget(parent), mUi(new Ui::SearchWidget), mTextEdit(textEdit) { 10 | mUi->setupUi(this); 11 | layout()->setContentsMargins(0, 0, 0, 0); 12 | setFocusProxy(mUi->lineEdit); 13 | 14 | mUi->countLabel->hide(); 15 | 16 | mUi->closeButton->setToolTip(tr("Close search bar")); 17 | 18 | connect(mUi->nextButton, &QToolButton::clicked, this, &SearchWidget::selectNextMatch); 19 | connect(mUi->previousButton, &QToolButton::clicked, this, &SearchWidget::selectPreviousMatch); 20 | connect(mTextEdit, &TextEdit::textChanged, this, &SearchWidget::onDocumentChanged); 21 | connect(mUi->lineEdit, &QLineEdit::textChanged, this, &SearchWidget::onLineEditChanged); 22 | connect(mUi->closeButton, &QToolButton::clicked, this, &SearchWidget::closeClicked); 23 | connect(mUi->lineEdit, &QLineEdit::returnPressed, this, &SearchWidget::selectNextMatch); 24 | } 25 | 26 | SearchWidget::~SearchWidget() { 27 | } 28 | 29 | void SearchWidget::initialize(const QString& text) { 30 | mUi->lineEdit->setFocus(); 31 | bool textChanged = mUi->lineEdit->text() != text; 32 | mUi->lineEdit->setText(text); 33 | if (!textChanged) { 34 | search(); 35 | } 36 | } 37 | 38 | void SearchWidget::uninitialize() { 39 | removeHighlights(); 40 | } 41 | 42 | void SearchWidget::search() { 43 | mPreviousText = mTextEdit->toPlainText(); 44 | 45 | QTextCursor cursor(mTextEdit->document()); 46 | cursor.beginEditBlock(); 47 | 48 | removeHighlights(); 49 | updateMatchPositions(); 50 | highlightMatches(); 51 | 52 | cursor.endEditBlock(); 53 | updateLineEdit(); 54 | updateCountLabel(); 55 | } 56 | 57 | void SearchWidget::selectNextMatch() { 58 | if (mMatchPositions.empty()) { 59 | return; 60 | } 61 | int minPosition = mTextEdit->textCursor().selectionStart(); 62 | auto it = std::find_if(mMatchPositions.begin(), 63 | mMatchPositions.end(), 64 | [minPosition](int position) { return position > minPosition; }); 65 | mCurrentMatch = it != mMatchPositions.end() ? std::distance(mMatchPositions.begin(), it) : 0; 66 | selectCurrentMatch(); 67 | } 68 | 69 | void SearchWidget::selectPreviousMatch() { 70 | if (mMatchPositions.empty()) { 71 | return; 72 | } 73 | int maxPosition = mTextEdit->textCursor().selectionStart(); 74 | auto it = std::find_if(mMatchPositions.rbegin(), 75 | mMatchPositions.rend(), 76 | [maxPosition](int position) { return position < maxPosition; }); 77 | 78 | if (it == mMatchPositions.rend()) { 79 | mCurrentMatch = mMatchPositions.size() - 1; 80 | } else { 81 | // rlast is the first element of mMatchPosition 82 | auto rlast = std::prev(mMatchPositions.rend()); 83 | mCurrentMatch = std::distance(it, rlast); 84 | } 85 | selectCurrentMatch(); 86 | } 87 | 88 | void SearchWidget::highlightMatches() { 89 | QList extraSelections; 90 | QColor highlightColor = Qt::yellow; 91 | for (int position : mMatchPositions) { 92 | QTextCursor cursor = mTextEdit->textCursor(); 93 | cursor.setPosition(position, QTextCursor::MoveAnchor); 94 | cursor.setPosition(position + mUi->lineEdit->text().size(), QTextCursor::KeepAnchor); 95 | 96 | QTextEdit::ExtraSelection currentWord; 97 | currentWord.format.setBackground(highlightColor); 98 | currentWord.cursor = cursor; 99 | extraSelections.append(currentWord); 100 | } 101 | mTextEdit->setExtraSelections(extraSelections); 102 | } 103 | 104 | void SearchWidget::removeHighlights() { 105 | mTextEdit->setExtraSelections({}); 106 | } 107 | 108 | void SearchWidget::onDocumentChanged() { 109 | if (!isVisible()) { 110 | return; 111 | } 112 | // When we highlight the search matches, documentChanged() is emitted. Compare current text with 113 | // the previous content and do not restart a search in this case, to prevent endless recursions. 114 | // This is not optimal, it would probably be better to use a syntax highlighter for matches, but 115 | // this is good enough for now. 116 | if (mPreviousText == mTextEdit->toPlainText()) { 117 | return; 118 | } 119 | search(); 120 | } 121 | 122 | void SearchWidget::onLineEditChanged() { 123 | search(); 124 | if (mCurrentMatch.has_value()) { 125 | selectCurrentMatch(); 126 | } 127 | } 128 | 129 | void SearchWidget::updateMatchPositions() { 130 | auto* document = mTextEdit->document(); 131 | QString searchString = mUi->lineEdit->text(); 132 | 133 | mMatchPositions.clear(); 134 | QTextCursor cursor(document); 135 | while (!cursor.isNull() && !cursor.atEnd()) { 136 | cursor = document->find(searchString, cursor); 137 | if (!cursor.isNull()) { 138 | mMatchPositions.push_back(cursor.selectionStart()); 139 | } 140 | } 141 | if (mMatchPositions.empty()) { 142 | mCurrentMatch.reset(); 143 | } else { 144 | mCurrentMatch = 0; 145 | } 146 | } 147 | 148 | void SearchWidget::selectCurrentMatch() { 149 | QTextDocument* document = mTextEdit->document(); 150 | QTextCursor cursor(document); 151 | cursor.beginEditBlock(); 152 | int startPosition = mMatchPositions.at(mCurrentMatch.value()); 153 | cursor.setPosition(startPosition, QTextCursor::MoveAnchor); 154 | cursor.setPosition(startPosition + mUi->lineEdit->text().size(), QTextCursor::KeepAnchor); 155 | mTextEdit->setTextCursor(cursor); 156 | cursor.endEditBlock(); 157 | updateCountLabel(); 158 | } 159 | 160 | void SearchWidget::updateCountLabel() { 161 | if (mCurrentMatch.has_value()) { 162 | mUi->countLabel->show(); 163 | QString str = QString("%1 / %2").arg(mCurrentMatch.value() + 1).arg(mMatchPositions.size()); 164 | mUi->countLabel->setText(str); 165 | } else { 166 | mUi->countLabel->hide(); 167 | } 168 | } 169 | 170 | static QColor mixColors(const QColor& c1, const QColor& c2, qreal k) { 171 | return QColor::fromRgbF(c1.redF() * (1 - k) + c2.redF() * k, 172 | c1.greenF() * (1 - k) + c2.greenF() * k, 173 | c1.blueF() * (1 - k) + c2.blueF() * k); 174 | } 175 | 176 | void SearchWidget::updateLineEdit() { 177 | static QPalette noMatchPalette = [this] { 178 | auto palette = mUi->lineEdit->palette(); 179 | auto baseColor = palette.color(QPalette::Base); 180 | palette.setColor(QPalette::Base, mixColors(baseColor, Qt::red, 0.3)); 181 | return palette; 182 | }(); 183 | bool noMatch = mMatchPositions.empty() && !mUi->lineEdit->text().isEmpty(); 184 | mUi->lineEdit->setPalette(noMatch ? noMatchPalette : palette()); 185 | } 186 | -------------------------------------------------------------------------------- /src/IndentExtension.cpp: -------------------------------------------------------------------------------- 1 | #include "IndentExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | static constexpr int INDENT_SIZE = 4; 9 | 10 | static int findBulletSize(const QStringRef& ref) { 11 | static QStringList bullets = {"- [ ] ", "- [x] ", "* [ ] ", "* [x] ", "- ", "* ", "> "}; 12 | for (auto bullet : bullets) { 13 | if (ref.startsWith(bullet)) { 14 | return bullet.length(); 15 | } 16 | } 17 | return 0; 18 | } 19 | 20 | struct PrefixInfo { 21 | QString text; 22 | bool isBullet = false; 23 | }; 24 | 25 | static PrefixInfo findCommonPrefix(const QString& line) { 26 | int idx; 27 | for (idx = 0; idx < line.length(); ++idx) { 28 | if (line[idx] != ' ') { 29 | break; 30 | } 31 | } 32 | int bulletSize = findBulletSize(line.midRef(idx)); 33 | PrefixInfo info; 34 | info.text = line.left(idx + bulletSize); 35 | info.isBullet = bulletSize > 0; 36 | return info; 37 | } 38 | 39 | static void indentLine(QTextCursor& cursor) { 40 | static QString spaces = QString(INDENT_SIZE, ' '); 41 | cursor.insertText(spaces); 42 | } 43 | 44 | static void unindentLine(QTextCursor& cursor) { 45 | const auto text = cursor.block().text(); 46 | for (int idx = 0; idx < std::min(INDENT_SIZE, text.size()) && text.at(idx) == ' '; ++idx) { 47 | cursor.deleteChar(); 48 | } 49 | } 50 | 51 | IndentExtension::IndentExtension(TextEdit* textEdit) 52 | : TextEditExtension(textEdit) 53 | , mIndentAction(new QAction(this)) 54 | , mUnindentAction(new QAction(this)) { 55 | mIndentAction->setText(tr("Indent")); 56 | mIndentAction->setShortcut(Qt::CTRL | Qt::Key_I); 57 | connect(mIndentAction, &QAction::triggered, this, [this] { processSelection(indentLine); }); 58 | mTextEdit->addAction(mIndentAction); 59 | 60 | mUnindentAction->setText(tr("Unindent")); 61 | mUnindentAction->setShortcuts({Qt::CTRL | Qt::Key_U, Qt::CTRL | Qt::SHIFT | Qt::Key_I}); 62 | connect(mUnindentAction, &QAction::triggered, this, [this] { processSelection(unindentLine); }); 63 | mTextEdit->addAction(mUnindentAction); 64 | } 65 | 66 | void IndentExtension::aboutToShowEditContextMenu(QMenu* menu, const QPoint& /*pos*/) { 67 | menu->addAction(mIndentAction); 68 | menu->addAction(mUnindentAction); 69 | menu->addSeparator(); 70 | } 71 | 72 | bool IndentExtension::keyPress(QKeyEvent* event) { 73 | if (event->key() == Qt::Key_Tab && event->modifiers() == 0) { 74 | onTabPressed(); 75 | return true; 76 | } 77 | if (event->key() == Qt::Key_Backtab) { 78 | processSelection(unindentLine); 79 | return true; 80 | } 81 | if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { 82 | onEnterPressed(); 83 | return true; 84 | } 85 | if (event->key() == Qt::Key_Backspace && canRemoveIndentation()) { 86 | removeIndentation(); 87 | return true; 88 | } 89 | return false; 90 | } 91 | 92 | bool IndentExtension::canRemoveIndentation() const { 93 | auto cursor = mTextEdit->textCursor(); 94 | int col = cursor.columnNumber(); 95 | if (col == 0) { 96 | return false; 97 | } 98 | QString line = cursor.block().text(); 99 | for (int i = col - 1; i >= 0; --i) { 100 | if (line.at(i) != ' ') { 101 | return false; 102 | } 103 | } 104 | return true; 105 | } 106 | 107 | bool IndentExtension::isAtStartOfListLine() const { 108 | auto cursor = mTextEdit->textCursor(); 109 | int columnNumber = cursor.columnNumber(); 110 | if (columnNumber == 0) { 111 | return false; 112 | } 113 | QString line = cursor.block().text(); 114 | auto prefixInfo = findCommonPrefix(line); 115 | 116 | return prefixInfo.isBullet && prefixInfo.text.length() == columnNumber; 117 | } 118 | 119 | bool IndentExtension::isAtEndOfLine() const { 120 | return mTextEdit->textCursor().atBlockEnd(); 121 | } 122 | 123 | bool IndentExtension::isIndentedLine() const { 124 | QString line = mTextEdit->textCursor().block().text(); 125 | return line.startsWith(QString(INDENT_SIZE, ' ')); 126 | } 127 | 128 | void IndentExtension::insertIndentation() { 129 | auto cursor = mTextEdit->textCursor(); 130 | int count = INDENT_SIZE - (cursor.columnNumber() % INDENT_SIZE); 131 | cursor.insertText(QString(count, ' ')); 132 | } 133 | 134 | void IndentExtension::processSelection(ProcessSelectionCallback callback) { 135 | auto cursor = mTextEdit->textCursor(); 136 | auto doc = mTextEdit->document(); 137 | QTextBlock startBlock, endBlock; 138 | if (cursor.hasSelection()) { 139 | auto start = cursor.selectionStart(); 140 | auto end = cursor.selectionEnd(); 141 | if (start > end) { 142 | std::swap(start, end); 143 | } 144 | startBlock = doc->findBlock(cursor.selectionStart()); 145 | endBlock = doc->findBlock(cursor.selectionEnd()); 146 | // If the end is not at the start of the block, select this block too 147 | if (end - endBlock.position() > 0) { 148 | endBlock = endBlock.next(); 149 | } 150 | } else { 151 | startBlock = cursor.block(); 152 | endBlock = startBlock.next(); 153 | } 154 | 155 | QTextCursor editCursor(startBlock); 156 | editCursor.beginEditBlock(); 157 | while (editCursor.block() != endBlock) { 158 | callback(editCursor); 159 | if (!editCursor.movePosition(QTextCursor::NextBlock)) { 160 | break; 161 | } 162 | } 163 | editCursor.endEditBlock(); 164 | } 165 | 166 | void IndentExtension::onTabPressed() { 167 | auto cursor = mTextEdit->textCursor(); 168 | if (cursor.selectedText().contains(QChar::ParagraphSeparator) || isAtStartOfListLine()) { 169 | processSelection(indentLine); 170 | } else { 171 | insertIndentation(); 172 | } 173 | } 174 | 175 | void IndentExtension::onEnterPressed() { 176 | auto cursor = mTextEdit->textCursor(); 177 | if (cursor.hasSelection()) { 178 | insertIndentedLine(); 179 | return; 180 | } 181 | bool atStartOfListLine = isAtStartOfListLine(); 182 | bool atEndOfLine = isAtEndOfLine(); 183 | if (atStartOfListLine && atEndOfLine) { 184 | if (isIndentedLine()) { 185 | processSelection(unindentLine); 186 | } else { 187 | cursor.movePosition(QTextCursor::StartOfLine, QTextCursor::KeepAnchor); 188 | cursor.removeSelectedText(); 189 | } 190 | } else { 191 | insertIndentedLine(); 192 | } 193 | } 194 | 195 | void IndentExtension::removeIndentation() { 196 | auto cursor = mTextEdit->textCursor(); 197 | int col = cursor.columnNumber(); 198 | int delta = (col % INDENT_SIZE == 0) ? INDENT_SIZE : (col % INDENT_SIZE); 199 | cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, delta); 200 | cursor.removeSelectedText(); 201 | } 202 | 203 | void IndentExtension::insertIndentedLine() { 204 | auto cursor = mTextEdit->textCursor(); 205 | if (cursor.columnNumber() > 0) { 206 | QString line = cursor.block().text(); 207 | QString prefix = findCommonPrefix(line).text; 208 | if (prefix.endsWith("[x] ")) { // Create unchecked task item 209 | mTextEdit->insertPlainText('\n' + prefix.left(prefix.size() - 4) + "[ ] "); 210 | } else { 211 | mTextEdit->insertPlainText('\n' + prefix); 212 | } 213 | } else { 214 | mTextEdit->insertPlainText("\n"); 215 | } 216 | mTextEdit->ensureCursorVisible(); 217 | } 218 | -------------------------------------------------------------------------------- /tests/MoveLinesExtensionTest.cpp: -------------------------------------------------------------------------------- 1 | #include "MoveLinesExtension.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "TextUtils.h" 10 | 11 | SCENARIO("movelines") { 12 | QMainWindow window; 13 | TextEdit* edit = new TextEdit; 14 | window.setCentralWidget(edit); 15 | MoveLinesExtension extension(edit); 16 | edit->addExtension(&extension); 17 | // Some tests won't work if the window is not visible (for example word-wrapping tests) 18 | window.show(); 19 | 20 | auto moveLinesDown = [&extension] { extension.moveDown(); }; 21 | auto moveLinesUp = [&extension] { extension.moveUp(); }; 22 | GIVEN("A cursor at the beginning of a line") { 23 | setupTextEditContent(edit, 24 | "1\n" 25 | "|2\n" 26 | "3\n"); 27 | 28 | WHEN("I press modifiers+down") { 29 | moveLinesDown(); 30 | THEN("The line is moved down") { 31 | REQUIRE(dumpTextEditContent(edit) 32 | == QString("1\n" 33 | "3\n" 34 | "|2\n")); 35 | } 36 | } 37 | WHEN("I press modifiers+up") { 38 | moveLinesUp(); 39 | THEN("The line is moved up") { 40 | REQUIRE(dumpTextEditContent(edit) 41 | == QString("|2\n" 42 | "1\n" 43 | "3\n")); 44 | } 45 | } 46 | } 47 | GIVEN("A cursor at the beginning of a line and no final \\n in the document") { 48 | setupTextEditContent(edit, 49 | "|1\n" 50 | "2"); 51 | 52 | WHEN("I press modifiers+down") { 53 | moveLinesDown(); 54 | THEN("The line is moved down") { 55 | REQUIRE(dumpTextEditContent(edit) 56 | == QString("2\n" 57 | "|1")); 58 | } 59 | } 60 | } 61 | GIVEN("A cursor in the middle of a line") { 62 | setupTextEditContent(edit, 63 | "1\n" 64 | "22|22\n" 65 | "3\n"); 66 | 67 | WHEN("I press modifiers+down") { 68 | moveLinesDown(); 69 | THEN("The line is moved down") { 70 | REQUIRE(dumpTextEditContent(edit) == QString("1\n3\n22|22\n")); 71 | } 72 | } 73 | WHEN("I press modifiers+up") { 74 | moveLinesUp(); 75 | THEN("The line is moved up") { 76 | REQUIRE(dumpTextEditContent(edit) 77 | == QString("22|22\n" 78 | "1\n" 79 | "3\n")); 80 | } 81 | } 82 | } 83 | GIVEN("A cursor after the last character") { 84 | setupTextEditContent(edit, 85 | "1\n" 86 | "2\n" 87 | "3|"); 88 | 89 | WHEN("I press modifiers+up") { 90 | moveLinesUp(); 91 | THEN("The line is moved up") { 92 | REQUIRE(dumpTextEditContent(edit) 93 | == QString("1\n" 94 | "3|\n" 95 | "2")); 96 | } 97 | } 98 | } 99 | GIVEN("A multi-line selection") { 100 | setupTextEditContent(edit, 101 | "1\n" 102 | "22*22\n" 103 | "333|3\n" 104 | "4\n"); 105 | WHEN("I press modifiers+down") { 106 | moveLinesDown(); 107 | THEN("The selected lines are moved down") { 108 | REQUIRE(dumpTextEditContent(edit) 109 | == QString("1\n" 110 | "4\n" 111 | "22*22\n" 112 | "333|3\n")); 113 | } 114 | } 115 | WHEN("I press modifiers+up") { 116 | moveLinesUp(); 117 | THEN("The lines are moved up") { 118 | REQUIRE(dumpTextEditContent(edit) 119 | == QString("22*22\n" 120 | "333|3\n" 121 | "1\n" 122 | "4\n")); 123 | } 124 | } 125 | } 126 | GIVEN("A reversed multi-line selection") { 127 | setupTextEditContent(edit, 128 | "1\n" 129 | "22|22\n" 130 | "333*3\n" 131 | "4\n"); 132 | 133 | WHEN("I press modifiers+down") { 134 | moveLinesDown(); 135 | THEN("The selected lines are moved down") { 136 | REQUIRE(dumpTextEditContent(edit) 137 | == QString("1\n" 138 | "4\n" 139 | "22|22\n" 140 | "333*3\n")); 141 | } 142 | } 143 | WHEN("I press modifiers+up") { 144 | moveLinesUp(); 145 | THEN("The lines are moved up") { 146 | REQUIRE(dumpTextEditContent(edit) 147 | == QString("22|22\n" 148 | "333*3\n" 149 | "1\n" 150 | "4\n")); 151 | } 152 | } 153 | } 154 | GIVEN("A cursor on the 2nd line of 4") { 155 | QString initialContent = "1\n" 156 | "|2\n" 157 | "3\n" 158 | "4"; 159 | setupTextEditContent(edit, initialContent); 160 | AND_GIVEN("I moved the line down") { 161 | moveLinesDown(); 162 | WHEN("I undo the changes") { 163 | edit->undo(); 164 | THEN("The text edit is back to its previous state") { 165 | REQUIRE(dumpTextEditContent(edit) == initialContent); 166 | } 167 | } 168 | } 169 | } 170 | GIVEN("A text with wrapped lines") { 171 | auto stringFill = [](char ch) { 172 | QString str; 173 | str.fill(ch, 100); 174 | return str; 175 | }; 176 | auto initialContent = 177 | QString('|') + stringFill('a') + '\n' + stringFill('b') + '\n' + stringFill('c'); 178 | setupTextEditContent(edit, initialContent); 179 | // TODO: Add a REQUIRE checking the first line is wrapped 180 | 181 | WHEN("I move the first line down") { 182 | moveLinesDown(); 183 | THEN("The entire wrapped line is moved") { 184 | auto expectedContent = 185 | stringFill('b') + "\n|" + stringFill('a') + '\n' + stringFill('c'); 186 | REQUIRE(dumpTextEditContent(edit) == expectedContent); 187 | WHEN("I undo the changes") { 188 | edit->undo(); 189 | THEN("The text edit is back to its previous state") { 190 | REQUIRE(dumpTextEditContent(edit) == initialContent); 191 | } 192 | } 193 | } 194 | } 195 | } 196 | GIVEN("A text with wrapped lines and the cursor is on the last line") { 197 | auto stringFill = [](char ch) { 198 | QString str; 199 | str.fill(ch, 100); 200 | return str; 201 | }; 202 | auto initialContent = stringFill('a') + '\n' + stringFill('b') + "\n|" + stringFill('c'); 203 | setupTextEditContent(edit, initialContent); 204 | // TODO: Add a REQUIRE checking the first line is wrapped 205 | 206 | WHEN("I move the line up") { 207 | moveLinesUp(); 208 | THEN("The entire wrapped line is moved") { 209 | auto expectedContent = 210 | stringFill('a') + "\n|" + stringFill('c') + '\n' + stringFill('b'); 211 | REQUIRE(dumpTextEditContent(edit) == expectedContent); 212 | WHEN("I undo the changes") { 213 | edit->undo(); 214 | THEN("The text edit is back to its previous state") { 215 | REQUIRE(dumpTextEditContent(edit) == initialContent); 216 | } 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/linux/nanonote.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | com.agateau.nanonote 5 | CC0-1.0 6 | Nanonote 7 | 8 | A minimalist note taking application. 9 | 10 | 11 |

12 | Nanonote is a minimalist note taking application. 13 |

14 |

15 | It automatically saves anything you type. Being minimalist means it has no synchronisation, does not support multiple documents, images or any advanced formatting (the only formatting is highlighting URLs and Markdown-like headings). 16 | Pixel Wheels is a retro top-down race game for Linux, macOS, Windows and Android. 17 |

18 |
19 | 20 | Utility 21 | TextEditor 22 | 23 | https://agateau.com/projects/nanonote 24 | https://github.com/agateau/nanonote/issues 25 | https://agateau.com/support 26 | com.agateau.nanonote.desktop 27 | 28 | 29 | 30 |

Added

31 |
    32 |
  • Nanonote now speaks Danish (Morgenkaff)
  • 33 |
  • Nanonote now speaks Dutch (Heimen Stoffels)
  • 34 |
  • Nanonote now speaks Polish (Marek Szumny)
  • 35 |
  • Nanonote now speaks Norwegian (Vidar Karlsen)
  • 36 |
37 |
38 |
39 | 40 | 41 |

Added

42 |
    43 |
  • Add support for Markdown-style tasks in lists (Daniel Laidig)
  • 44 |
  • Add tips page (Aurelien Gateau)
  • 45 |
  • Nanonote now highlights Markdown-like headings (Aurelien Gateau)
  • 46 |
  • Nanonote now speaks Czech (Amerey)
  • 47 |
48 |

Changed

49 |
    50 |
  • Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig)
  • 51 |
52 |

Fixed

53 |
    54 |
  • Make sure standard actions like Copy or Paste are translated (Aurelien Gateau)
  • 55 |
  • Show keyboard shortcuts in context menus on macOS (Daniel Laidig)
  • 56 |
  • Do not change cursor to pointing-hand when not over a link (Aurelien Gateau)
  • 57 |
58 |
59 |
60 | 61 | 62 |

Fixed

63 |
    64 |
  • Fixed a typo in the Appstream ID, which made creating a Flatpak for the app complicated.
  • 65 |
66 |
67 |
68 | 69 | 70 |

Added

71 |
    72 |
  • Nanonote now highlights Markdown-like headings.
  • 73 |
74 |
75 |
76 | 77 | 78 |

Added

79 |
    80 |
  • Add support for Markdown-style tasks in lists (Daniel Laidig)
  • 81 |
  • Add tips page (Aurelien Gateau)
  • 82 |
83 |

Changed

84 |
    85 |
  • Use Ctrl+G to open links and Ctrl+Enter for tasks (Daniel Laidig)
  • 86 |
87 |

Fixed

88 |
    89 |
  • Make sure standard actions like Copy or Paste are translated (Aurelien Gateau)
  • 90 |
  • Show keyboard shortcuts in context menus on macOS (Daniel Laidig)
  • 91 |
  • Do not change cursor to pointing-hand when not over a link (Aurelien Gateau)
  • 92 |
93 |

Internals

94 |
    95 |
  • CI: Bump Ubuntu to 20.04 and macOS to 11 (Aurelien Gateau)
  • 96 |
  • CI: Install clang-format from muttleyxd/clang-tools-static-binaries (Aurelien Gateau)
  • 97 |
  • Bump Qt to 5.15.2 on macOS and Windows (Aurelien Gateau)
  • 98 |
  • Update singleaplication to 3.3.4 (Aurelien Gateau)
  • 99 |
  • Update Catch2 to 3.3.0 (Aurelien Gateau)
  • 100 |
101 |
102 |
103 | 104 | 105 |

Changed

106 |
    107 |
  • Update Spanish translation (Victorhck)
  • 108 |
109 |

Fixed

110 |
    111 |
  • Properly encode URL of the note path (Aurelien Gateau)
  • 112 |
  • Fix untranslated text in About tab on Linux (Aurelien Gateau)
  • 113 |
114 |
115 |
116 | 117 | 118 |

Added

119 |
    120 |
  • You can now search inside your notes with the new search bar (Pavol Oresky)
  • 121 |
  • You can now move selected lines up and down with Alt+Shift+Up and Down (Aurelien Gateau)
  • 122 |
  • macOS dmg (Aurelien Gateau)
  • 123 |
  • Windows installer (Aurelien Gateau)
  • 124 |
125 |

Changed

126 |
    127 |
  • Reorganized context menu: added "Edit" and "View" submenus (Aurelien Gateau)
  • 128 |
129 |
130 |
131 | 132 | 133 |

Added

134 |
    135 |
  • New German translation by Vinzenz Vietzke
  • 136 |
  • Allow changing the font size using Ctrl + mouse wheel (Daniel Laidig)
  • 137 |
  • Use the link color of the color theme instead of an hardcoded blue (Daniel Laidig)
  • 138 |
  • Added a way to reset the font size to the default value (Daniel Laidig)
  • 139 |
140 |

Fixed

141 |
    142 |
  • Added explanation of how to open URLs to the welcome text (Robert Barat)
  • 143 |
  • Allow '@' in URLs (Aurelien Gateau)
  • 144 |
  • Use QSaveFile for safer saving (Aurelien Gateau)
  • 145 |
146 |
147 |
148 | 149 | 150 |

Added

151 |
    152 |
  • Pressing tab now indents the whole line when the cursor is at the beginning of a list item (Daniel Laidig).
  • 153 |
  • Pressing Enter on an empty list item now unindents, then removes the bullet (Aurelien Gateau).
  • 154 |
  • Added French and Spanish translations (Aurelien Gateau, Victorhck).
  • 155 |
156 |

Fixed

157 |
    158 |
  • Improved url detection: '+', '%' and '~' are now allowed in the middle of urls (Aurelien Gateau).
  • 159 |
  • Fixed wrong indentation behavior in upward selections (Aurelien Gateau).
  • 160 |
161 |
162 |
163 | 164 | 165 |

Added

166 |
    167 |
  • Added unit-tests.
  • 168 |
  • Added Travis integration.
  • 169 |
  • Added rpm and deb packages generated using CPack.
  • 170 |
171 |

Fixed

172 |
    173 |
  • Fixed indentation and make it respect indentation columns.
  • 174 |
  • Made it possible to indent/unindent selected lines with Tab/Shift+Tab.
  • 175 |
  • Update welcome text to reflect current shortcuts.
  • 176 |
177 |
178 |
179 | 180 | 181 |

Added

182 |
    183 |
  • First release
  • 184 |
185 |
186 |
187 |
188 | 189 | nanonote 190 | 191 | GPL-3.0-or-later 192 | 193 | 194 | https://github.com/agateau/nanonote/raw/1.3.91/screenshot.png 195 | 196 | 197 | 198 | 199 | note taking 200 | minimalist note app 201 | simple note editor 202 | 203 |
204 | -------------------------------------------------------------------------------- /src/SettingsDialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SettingsDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 435 10 | 290 11 | 12 | 13 | 14 | Settings 15 | 16 | 17 | 18 | 19 | 20 | Qt::Horizontal 21 | 22 | 23 | QDialogButtonBox::Close 24 | 25 | 26 | 27 | 28 | 29 | 30 | 0 31 | 32 | 33 | 34 | Configuration 35 | 36 | 37 | 38 | 39 | 40 | Font family: 41 | 42 | 43 | fontComboBox 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Size: 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | Qt::Horizontal 66 | 67 | 68 | 69 | 0 70 | 20 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | Qt::Vertical 81 | 82 | 83 | QSizePolicy::Fixed 84 | 85 | 86 | 87 | 20 88 | 24 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | Qt::Vertical 97 | 98 | 99 | 100 | 20 101 | 40 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | [note location] 110 | 111 | 112 | true 113 | 114 | 115 | true 116 | 117 | 118 | Qt::TextBrowserInteraction 119 | 120 | 121 | 122 | 123 | 124 | 125 | Your notes are stored here: 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | About 134 | 135 | 136 | 137 | 138 | 139 | 140 | 0 141 | 0 142 | 143 | 144 | 145 | [about] 146 | 147 | 148 | Qt::RichText 149 | 150 | 151 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop 152 | 153 | 154 | true 155 | 156 | 157 | true 158 | 159 | 160 | Qt::TextBrowserInteraction 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 0 169 | 0 170 | 171 | 172 | 173 | [support] 174 | 175 | 176 | true 177 | 178 | 179 | true 180 | 181 | 182 | Qt::TextBrowserInteraction 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | :/appicon/sc-apps-nanonote.svg 193 | 194 | 195 | false 196 | 197 | 198 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop 199 | 200 | 201 | 202 | 203 | 204 | 205 | Qt::Vertical 206 | 207 | 208 | QSizePolicy::Fixed 209 | 210 | 211 | 212 | 20 213 | 12 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | buttonBox 230 | accepted() 231 | SettingsDialog 232 | accept() 233 | 234 | 235 | 257 236 | 280 237 | 238 | 239 | 157 240 | 274 241 | 242 | 243 | 244 | 245 | buttonBox 246 | rejected() 247 | SettingsDialog 248 | reject() 249 | 250 | 251 | 316 252 | 260 253 | 254 | 255 | 286 256 | 274 257 | 258 | 259 | 260 | 261 | 262 | -------------------------------------------------------------------------------- /src/appicon/sc-apps-nanonote.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 23 | 25 | image/svg+xml 26 | 28 | 29 | 30 | 31 | 32 | 34 | 59 | 72 | 73 | 77 | 81 | 87 | 93 | 99 | 105 | 110 | 115 | 116 | 120 | 127 | 135 | 143 | 148 | 154 | 160 | 161 | 169 | 170 | -------------------------------------------------------------------------------- /src/translations/nanonote_nl.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IndentExtension 6 | 7 | 8 | Indent 9 | Inspringen 10 | 11 | 12 | 13 | Unindent 14 | Inspringing opheffen 15 | 16 | 17 | 18 | LinkExtension 19 | 20 | 21 | Go to link 22 | Link openen 23 | 24 | 25 | 26 | Copy link address 27 | Linkadres kopiëren 28 | 29 | 30 | 31 | MainWindow 32 | 33 | 34 | Increase Font Size 35 | Tekst vergroten 36 | 37 | 38 | 39 | Decrease Font Size 40 | Tekst verkleinen 41 | 42 | 43 | 44 | Reset Font Size 45 | Standaard tekstgrootte 46 | 47 | 48 | 49 | Always on Top 50 | Altijd op voorgrond 51 | 52 | 53 | 54 | Settings | About... 55 | Instellingen | Over… 56 | 57 | 58 | 59 | Find 60 | Zoeken 61 | 62 | 63 | 64 | Welcome to Nanonote! 65 | 66 | Nanonote is a minimalist note taking application. 67 | 68 | Anything you type here is automatically saved on your disk. 69 | 70 | The only UI is the context menu, try it out! 71 | 72 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 73 | 74 | It also has a few handy editing features, like auto-bullet lists: 75 | 76 | - Try to move the cursor at the end of this line and press Enter 77 | - This works for 78 | - nested lists 79 | * asterisks 80 | - [ ] checkable list entries (checkboxes can be toggled with Ctrl+Enter or Ctrl+click) 81 | 82 | You can open URLs using Ctrl+click or Ctrl+G while your cursor is inside an URL. 83 | Try clicking on this one for example, to learn more tricks: 84 | 85 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 86 | 87 | That's all there is to say, now you can erase this text and start taking notes! 88 | 89 | Welkom in Nanonote! 90 | 91 | Nanonote is een minimalistisch notitieprogramma. 92 | 93 | Alles wat u typt wordt automatisch opgeslagen. 94 | 95 | Het enige grafische aan het programma is het rechtermuisknopmenu. 96 | 97 | Zoals u kunt zien in het rechtermuisknopmenu, is er een functie ‘Altijd op voorgrond’. Zo heeft u het venster altijd bij de hand. 98 | 99 | Ook zijn er enkele handige functies, zoals automatisch genummerde lijsten: 100 | 101 | - Verplaats de cursor naar het einde van deze regel en druk op enter 102 | - Dit werkt bij 103 | - ingesprongen lijsten 104 | * sterretjes 105 | - [ ] aankruisbare lijstitems (toon/verberg ze met Ctrl+Enter of Ctrl+klik) 106 | 107 | Open url's met Ctrl+klik of Ctrl+G als de cursor op een url staat. 108 | Probeer bijvoorbeeld deze url uit, waar u tips kunt lezen (Engels): 109 | 110 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 111 | 112 | Meer valt er niet over te vertellen. Wis deze tekst en ga aan de slag! 113 | 114 | 115 | 116 | 117 | MoveLinesExtension 118 | 119 | 120 | Move selected lines up 121 | Selectie omhoog verplaatsen 122 | 123 | 124 | 125 | Move selected lines down 126 | Selectie omlaag verplaatsen 127 | 128 | 129 | 130 | SearchWidget 131 | 132 | 133 | Previous 134 | Vorige 135 | 136 | 137 | 138 | Next 139 | Volgende 140 | 141 | 142 | 143 | Close search bar 144 | Zoekbalk sluiten 145 | 146 | 147 | 148 | SettingsDialog 149 | 150 | 151 | Settings 152 | Instellingen 153 | 154 | 155 | 156 | Configuration 157 | Instellingen 158 | 159 | 160 | 161 | Font family: 162 | Lettertype: 163 | 164 | 165 | 166 | Size: 167 | Grootte: 168 | 169 | 170 | 171 | Your notes are stored here: 172 | Uw notities worden opgeslagen in 173 | 174 | 175 | 176 | About 177 | Over 178 | 179 | 180 | 181 | <h2>Nanonote %1</h2> 182 | <p>A minimalist note taking application.</p> 183 | <p> 184 | &bull; Project page: <a href='%2'>%2</a><br> 185 | &bull; Tips and tricks: <a href='%3'>%3</a> 186 | </p> 187 | 188 | %1: version, %2: project url, %3: tips and trick page url 189 | <h2>Nanonote %1</h2> 190 | <p>Een minimalistisch notitieprogramma.</p> 191 | <p> 192 | &bull; Website: <a href='%2'>%2</a><br> 193 | &bull; Tips (Engels): <a href='%3'>%3</a> 194 | </p> 195 | 196 | 197 | 198 | 199 | <p>Hi,</p> 200 | <p>I hope you enjoy Nanonote!</p> 201 | <p>If you do, it would be lovely if you could <a href='%1'>support my work</a> on free and open source software.</p> 202 | <p align="right">― Aurélien</p> 203 | %1: support url 204 | %1: ondersteuning 205 | 206 | 207 | 208 | TaskExtension 209 | 210 | 211 | Insert/toggle task 212 | Taak invoegen/omschakelen 213 | 214 | 215 | 216 | TextEdit 217 | 218 | 219 | Edit 220 | Bewerken 221 | 222 | 223 | 224 | View 225 | Beeld 226 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /src/translations/nanonote_no.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IndentExtension 6 | 7 | 8 | Indent 9 | Innrykk 10 | 11 | 12 | 13 | Unindent 14 | Fjern innrykk 15 | 16 | 17 | 18 | LinkExtension 19 | 20 | 21 | Go to link 22 | Åpne link 23 | 24 | 25 | 26 | Copy link address 27 | Kopier link 28 | 29 | 30 | 31 | MainWindow 32 | 33 | 34 | Increase Font Size 35 | Øk skriftstørrelsen 36 | 37 | 38 | 39 | Decrease Font Size 40 | Forminsk skriftstørrelsen 41 | 42 | 43 | 44 | Reset Font Size 45 | Nullstill skriftstørrelsen 46 | 47 | 48 | 49 | Always on Top 50 | Hold alltid øverst 51 | 52 | 53 | 54 | Settings | About... 55 | Innstillinger | Om ... 56 | 57 | 58 | 59 | Find 60 | Søk 61 | 62 | 63 | 64 | Welcome to Nanonote! 65 | 66 | Nanonote is a minimalist note taking application. 67 | 68 | Anything you type here is automatically saved on your disk. 69 | 70 | The only UI is the context menu, try it out! 71 | 72 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 73 | 74 | It also has a few handy editing features, like auto-bullet lists: 75 | 76 | - Try to move the cursor at the end of this line and press Enter 77 | - This works for 78 | - nested lists 79 | * asterisks 80 | - [ ] checkable list entries (checkboxes can be toggled with Ctrl+Enter or Ctrl+click) 81 | 82 | You can open URLs using Ctrl+click or Ctrl+G while your cursor is inside an URL. 83 | Try clicking on this one for example, to learn more tricks: 84 | 85 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 86 | 87 | That's all there is to say, now you can erase this text and start taking notes! 88 | 89 | Velkommen til Nanonote! 90 | 91 | Nanonote er et minimalistisk program for notatskriving. 92 | 93 | Alt du skriver her blir automatisk lagret på din disk. 94 | 95 | Det eneste UI er kontekstmenyen, prøv den! 96 | 97 | Som du kan se i kontekstmenyen har Nanonote en "Hold alltid øverst"-funksjon. Denne funksjonen gjør det praktisk å holde vinduet lett tilgjengelig. 98 | 99 | Det er også andre smarte funksjoner, som automatiske punktlister: 100 | 101 | - Prøv å flytte markøren til enden av denne linje og trykk Enter 102 | - Dette virker også for 103 | - nestede lister 104 | * Asterisker 105 | - [ ] Avkryssingsfelter (Avkryssingsfelt kan skiftes mellom utfylt og tomt med Ctrl+Enter mens markøren er på tilhørende linje eller Ctrl+museklikk på avkryssingsfeltet) 106 | 107 | Man kan åpne linker med Ctrl+museklikk eller Ctrl+G mens markøren er i linkens tekst. 108 | Prøv f.eks. å klikke på denne for å lære flere triks: 109 | 110 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 111 | 112 | Det er alt det er å si, slett denne teksten og begynn å ta notater! 113 | 114 | 115 | 116 | 117 | MoveLinesExtension 118 | 119 | 120 | Move selected lines up 121 | Flytt markert tekst opp 122 | 123 | 124 | 125 | Move selected lines down 126 | Flytt markert tekst ned 127 | 128 | 129 | 130 | SearchWidget 131 | 132 | 133 | Previous 134 | Forrige 135 | 136 | 137 | 138 | Next 139 | Neste 140 | 141 | 142 | 143 | Close search bar 144 | Lukk søkefeltet 145 | 146 | 147 | 148 | SettingsDialog 149 | 150 | 151 | Settings 152 | Innstillinger 153 | 154 | 155 | 156 | Configuration 157 | Konfigurasjon 158 | 159 | 160 | 161 | Font family: 162 | Skrifttype: 163 | 164 | 165 | 166 | Size: 167 | Str.: 168 | 169 | 170 | 171 | Your notes are stored here: 172 | Plassering av notater: 173 | 174 | 175 | 176 | About 177 | Om 178 | 179 | 180 | 181 | <h2>Nanonote %1</h2> 182 | <p>A minimalist note taking application.</p> 183 | <p> 184 | &bull; Project page: <a href='%2'>%2</a><br> 185 | &bull; Tips and tricks: <a href='%3'>%3</a> 186 | </p> 187 | 188 | %1: version, %2: project url, %3: tips and trick page url 189 | <h2>Nanonote %1</h2> 190 | <p>Nanonote er en minimalistisk app til notetagning.</p> 191 | <p> 192 | &bull; Projektets side: <a href='%2'>%2</a><br> 193 | &bull; Tips og triks: <a href='%3'>%3</a> 194 | </p> 195 | 196 | 197 | 198 | 199 | <p>Hi,</p> 200 | <p>I hope you enjoy Nanonote!</p> 201 | <p>If you do, it would be lovely if you could <a href='%1'>support my work</a> on free and open source software.</p> 202 | <p align="right">― Aurélien</p> 203 | %1: support url 204 | <p>Hej,</p> 205 | <p>Jeg håper du har glede av Nanonote!</p> 206 | <p>Om du har, ville det være flott om du kunne <a href='%1'>støttet mitt arbeid</a> med fri og åpen kildekode.</p> 207 | <p align="right">― Aurélien</p> 208 | 209 | 210 | 211 | TaskExtension 212 | 213 | 214 | Insert/toggle task 215 | Sett inn/endre avkryssingsfelt 216 | 217 | 218 | 219 | TextEdit 220 | 221 | 222 | Edit 223 | Rediger 224 | 225 | 226 | 227 | View 228 | Visning 229 | 230 | 231 | 232 | -------------------------------------------------------------------------------- /src/translations/nanonote_da.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IndentExtension 6 | 7 | 8 | Indent 9 | Indryk 10 | 11 | 12 | 13 | Unindent 14 | Fjern indryk 15 | 16 | 17 | 18 | LinkExtension 19 | 20 | 21 | Go to link 22 | Åbn link 23 | 24 | 25 | 26 | Copy link address 27 | Could be "Kopier link adresse". But the used one seems to be used more. 28 | Kopier link 29 | 30 | 31 | 32 | MainWindow 33 | 34 | 35 | Increase Font Size 36 | Forøg skriftstørrelsen 37 | 38 | 39 | 40 | Decrease Font Size 41 | Formindsk skriftstørrelsen 42 | 43 | 44 | 45 | Reset Font Size 46 | Nulstil skriftstørrelsen 47 | 48 | 49 | 50 | Always on Top 51 | Taken directly from the translation team for kde 52 | Hold altid øverst 53 | 54 | 55 | 56 | Settings | About... 57 | Indstillinger|Om... 58 | 59 | 60 | 61 | Find 62 | Søg 63 | 64 | 65 | 66 | Welcome to Nanonote! 67 | 68 | Nanonote is a minimalist note taking application. 69 | 70 | Anything you type here is automatically saved on your disk. 71 | 72 | The only UI is the context menu, try it out! 73 | 74 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 75 | 76 | It also has a few handy editing features, like auto-bullet lists: 77 | 78 | - Try to move the cursor at the end of this line and press Enter 79 | - This works for 80 | - nested lists 81 | * asterisks 82 | - [ ] checkable list entries (checkboxes can be toggled with Ctrl+Enter or Ctrl+click) 83 | 84 | You can open URLs using Ctrl+click or Ctrl+G while your cursor is inside an URL. 85 | Try clicking on this one for example, to learn more tricks: 86 | 87 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 88 | 89 | That's all there is to say, now you can erase this text and start taking notes! 90 | 91 | Velkommen til Nanonote! 92 | 93 | Nanonote er en minimalistisk app til notetagning. 94 | 95 | Alt hvad du skriver her, bliver automatisk gemt på din disk. 96 | 97 | Det eneste UI er kontekstmenuen, prøv den! 98 | 99 | Som du kan se i kontekstmenuen, har Nanonote en "Hold altid øverst" funktion. Denne funktion gør det nemt at beholde vinduet fremme. 100 | 101 | Der er også andre smarte funktioner, såsom automatiske punktlister: 102 | 103 | - Prøv at flytte markøren til enden af denne linje og tryk Enter 104 | - Dette virker også til 105 | - indlejrede lister 106 | * Asterisk 107 | - [ ] Afkrydsningsfelter (Afkrydsningsfelter kan skiftes imellem udfyldt og tomt med Ctrl+Enter, imens markøren er på tilhørende linje eller Ctrl+museklik på afkrydsningsfeltet) 108 | 109 | Man kan åbne links med Ctrl+museklik eller Ctrl+G imens markøren er i linkets tekst. 110 | Prøv f.eks. at klikke på denne for at lære flere tricks: 111 | 112 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 113 | 114 | Det er alt hvad der er at sige, slet denne tekst og begynd at tage noter! 115 | 116 | 117 | 118 | 119 | MoveLinesExtension 120 | 121 | 122 | Move selected lines up 123 | Flyt markeret tekst op 124 | 125 | 126 | 127 | Move selected lines down 128 | Flyt markeret tekst ned 129 | 130 | 131 | 132 | SearchWidget 133 | 134 | 135 | Previous 136 | Forrige 137 | 138 | 139 | 140 | Next 141 | Næste 142 | 143 | 144 | 145 | Close search bar 146 | Luk søgefeltet 147 | 148 | 149 | 150 | SettingsDialog 151 | 152 | 153 | Settings 154 | Indstillinger 155 | 156 | 157 | 158 | Configuration 159 | Konfiguration 160 | 161 | 162 | 163 | Font family: 164 | Skrifttype: 165 | 166 | 167 | 168 | Size: 169 | Str.: 170 | 171 | 172 | 173 | Your notes are stored here: 174 | Placering af noter: 175 | 176 | 177 | 178 | About 179 | Om 180 | 181 | 182 | 183 | <h2>Nanonote %1</h2> 184 | <p>A minimalist note taking application.</p> 185 | <p> 186 | &bull; Project page: <a href='%2'>%2</a><br> 187 | &bull; Tips and tricks: <a href='%3'>%3</a> 188 | </p> 189 | 190 | %1: version, %2: project url, %3: tips and trick page url 191 | <h2>Nanonote %1</h2> 192 | <p>Nanonote er en minimalistisk app til notetagning.</p> 193 | <p> 194 | &bull; Projektets side: <a href='%2'>%2</a><br> 195 | &bull; Tips og tricks: <a href='%3'>%3</a> 196 | </p> 197 | 198 | 199 | 200 | 201 | <p>Hi,</p> 202 | <p>I hope you enjoy Nanonote!</p> 203 | <p>If you do, it would be lovely if you could <a href='%1'>support my work</a> on free and open source software.</p> 204 | <p align="right">― Aurélien</p> 205 | %1: support url 206 | <p>Hej,</p> 207 | <p>Jeg håber du har glæde af Nanonote!</p> 208 | <p>Hvis du gør, ville det være dejligt hvis du kunne <a href='%1'>støtte mit arbejde</a> med fri og open source software.</p> 209 | <p align="right">― Aurélien</p> 210 | 211 | 212 | 213 | TaskExtension 214 | 215 | 216 | Insert/toggle task 217 | Indsæt/skift afkrydsningsfelt 218 | 219 | 220 | 221 | TextEdit 222 | 223 | 224 | Edit 225 | Rediger 226 | 227 | 228 | 229 | View 230 | Visning 231 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /src/translations/nanonote_en.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IndentExtension 6 | 7 | 8 | Indent 9 | Indent 10 | 11 | 12 | 13 | Unindent 14 | Unindent 15 | 16 | 17 | 18 | LinkExtension 19 | 20 | 21 | Go to link 22 | 23 | 24 | 25 | 26 | Copy link address 27 | Copy link address 28 | 29 | 30 | Open link 31 | Open link 32 | 33 | 34 | 35 | MainWindow 36 | 37 | 38 | Increase Font Size 39 | Increase Font Size 40 | 41 | 42 | 43 | Decrease Font Size 44 | Decrease Font Size 45 | 46 | 47 | 48 | Reset Font Size 49 | Reset Font Size 50 | 51 | 52 | 53 | Always on Top 54 | Always on Top 55 | 56 | 57 | 58 | Settings | About... 59 | Settings | About... 60 | 61 | 62 | 63 | Find 64 | Find 65 | 66 | 67 | 68 | Welcome to Nanonote! 69 | 70 | Nanonote is a minimalist note taking application. 71 | 72 | Anything you type here is automatically saved on your disk. 73 | 74 | The only UI is the context menu, try it out! 75 | 76 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 77 | 78 | It also has a few handy editing features, like auto-bullet lists: 79 | 80 | - Try to move the cursor at the end of this line and press Enter 81 | - This works for 82 | - nested lists 83 | * asterisks 84 | - [ ] checkable list entries (checkboxes can be toggled with Ctrl+Enter or Ctrl+click) 85 | 86 | You can open URLs using Ctrl+click or Ctrl+G while your cursor is inside an URL. 87 | Try clicking on this one for example, to learn more tricks: 88 | 89 | https://github.com/agateau/nanonote/blob/master/docs/tips.md 90 | 91 | That's all there is to say, now you can erase this text and start taking notes! 92 | 93 | 94 | 95 | 96 | Welcome to Nanonote! 97 | 98 | Nanonote is a minimalist note taking application. 99 | 100 | It's meant for short-lived notes. Anything you type here is automatically saved on your disk. 101 | 102 | The only UI is the context menu, try it out! 103 | 104 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 105 | 106 | It also has a few handy editing features, like auto-bullet lists: 107 | 108 | - Try to move the cursor at the end of this line and press Enter 109 | - This works for 110 | - nested lists 111 | * and asterisks 112 | 113 | You can also open urls using Control + click or Control + Enter while your cursor is inside a URL. You can try clicking on this one for example: https://github.com/agateau/nanonote. 114 | 115 | Finally, you can indent selected lines with Tab or Ctrl+I and unindent them with Shift+Tab or Ctrl+U. 116 | 117 | That's all there is to say, now you can erase this text and start taking notes! 118 | 119 | Welcome to Nanonote! 120 | 121 | Nanonote is a minimalist note taking application. 122 | 123 | It's meant for short-lived notes. Anything you type here is automatically saved on your disk. 124 | 125 | The only UI is the context menu, try it out! 126 | 127 | As you can see in the context menu, Nanonote has an "Always on Top" mode. This feature is handy to keep the window around. 128 | 129 | It also has a few handy editing features, like auto-bullet lists: 130 | 131 | - Try to move the cursor at the end of this line and press Enter 132 | - This works for 133 | - nested lists 134 | * and asterisks 135 | 136 | You can also open urls using Control + click or Control + Enter while your cursor is inside a URL. You can try clicking on this one for example: https://github.com/agateau/nanonote. 137 | 138 | Finally, you can indent selected lines with Tab or Ctrl+I and unindent them with Shift+Tab or Ctrl+U. 139 | 140 | That's all there is to say, now you can erase this text and start taking notes! 141 | 142 | 143 | 144 | 145 | MoveLinesExtension 146 | 147 | 148 | Move selected lines up 149 | Move selected lines up 150 | 151 | 152 | 153 | Move selected lines down 154 | Move selected lines down 155 | 156 | 157 | 158 | SearchWidget 159 | 160 | 161 | Previous 162 | Previous 163 | 164 | 165 | 166 | Next 167 | Next 168 | 169 | 170 | 171 | Close search bar 172 | Close search bar 173 | 174 | 175 | 176 | SettingsDialog 177 | 178 | 179 | Settings 180 | Settings 181 | 182 | 183 | 184 | Configuration 185 | Configuration 186 | 187 | 188 | 189 | Font family: 190 | Font family: 191 | 192 | 193 | 194 | Size: 195 | Size: 196 | 197 | 198 | 199 | Your notes are stored here: 200 | Your notes are stored here: 201 | 202 | 203 | 204 | About 205 | About 206 | 207 | 208 | <h2>Nanonote %1</h2> 209 | <p>A minimalist note taking application.<br> 210 | <a href='%2'>%2</a></p> 211 | %1: version, %2: project url 212 | <h2>Nanonote %1</h2> 213 | <p>A minimalist note taking application.<br> 214 | <a href='%2'>%2</a></p> 215 | 216 | 217 | 218 | <h2>Nanonote %1</h2> 219 | <p>A minimalist note taking application.</p> 220 | <p> 221 | &bull; Project page: <a href='%2'>%2</a><br> 222 | &bull; Tips and tricks: <a href='%3'>%3</a> 223 | </p> 224 | 225 | %1: version, %2: project url, %3: tips and trick page url 226 | 227 | 228 | 229 | 230 | <p>Hi,</p> 231 | <p>I hope you enjoy Nanonote!</p> 232 | <p>If you do, it would be lovely if you could <a href='%1'>support my work</a> on free and open source software.</p> 233 | <p align="right">― Aurélien</p> 234 | %1: support url 235 | <p>Hi,</p> 236 | <p>I hope you enjoy Nanonote!</p> 237 | <p>If you do, it would be lovely if you could <a href='%1'>support my work</a> on free and open source software.</p> 238 | <p align="right">― Aurélien</p> 239 | 240 | 241 | 242 | TaskExtension 243 | 244 | 245 | Insert/toggle task 246 | 247 | 248 | 249 | 250 | TextEdit 251 | 252 | 253 | Edit 254 | Edit 255 | 256 | 257 | 258 | View 259 | View 260 | 261 | 262 | 263 | --------------------------------------------------------------------------------