├── .cmake-format ├── py ├── MANIFEST.in ├── bindings.h ├── setup.py ├── bindings.xml ├── EverloadTags │ ├── __init__.py │ └── __init__.pyi ├── demo.py └── bindings.cmake ├── screenshot └── examples.png ├── .gitignore ├── test ├── app │ ├── main.cpp │ ├── form.h │ ├── form.ui │ └── form.cpp └── util.cpp ├── everload_tags-config.cmake.in ├── src └── everload_tags │ ├── config.cpp │ ├── util.hpp │ ├── scope_exit.hpp │ ├── common.hpp │ ├── tags_line_edit.cpp │ └── tags_edit.cpp ├── README.md ├── LICENSE ├── .github └── workflows │ ├── ci.yml │ └── ci_pybindings.yml ├── common.cmake ├── include └── everload_tags │ ├── tags_line_edit.hpp │ ├── tags_edit.hpp │ └── config.hpp ├── .clang-format ├── CMakeLists.txt └── utils └── pyside_config.py /.cmake-format: -------------------------------------------------------------------------------- 1 | tab_size = 4 2 | 3 | -------------------------------------------------------------------------------- /py/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ./EverloadTags/lib/*.so 2 | -------------------------------------------------------------------------------- /screenshot/examples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicktrandafil/tags/HEAD/screenshot/examples.png -------------------------------------------------------------------------------- /py/bindings.h: -------------------------------------------------------------------------------- 1 | #ifndef BINDINGS_H 2 | #define BINDINGS_H 3 | #include "config.hpp" 4 | #include "tags_edit.hpp" 5 | #include "tags_line_edit.hpp" 6 | #endif 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.txt.user* 2 | build/ 3 | log 4 | 5 | # python module files 6 | *.pyc 7 | py/*.egg-info 8 | py/EverloadTags/lib/* 9 | py/EverloadTags/include/** 10 | -------------------------------------------------------------------------------- /test/app/main.cpp: -------------------------------------------------------------------------------- 1 | #include "form.h" 2 | 3 | #include 4 | 5 | int main(int argc, char* argv[]) { 6 | QApplication app(argc, argv); 7 | Form form; 8 | form.show(); 9 | return app.exec(); 10 | } 11 | -------------------------------------------------------------------------------- /everload_tags-config.cmake.in: -------------------------------------------------------------------------------- 1 | include(CMakeFindDependencyMacro) 2 | 3 | find_dependency(QT NAMES Qt6 Qt5 COMPONENTS Widgets) 4 | find_dependency(Qt${QT_VERSION_MAJOR} COMPONENTS Widgets) 5 | 6 | include(${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@-targets.cmake) 7 | 8 | set(@PROJECT_NAME@_LIBRARY @PROJECT_NAME@) 9 | set(@PROJECT_NAME@_LIBRARIES @PROJECT_NAME@) 10 | -------------------------------------------------------------------------------- /py/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="EverloadTags", 5 | version="0.1", 6 | description="python bindinds for tag controls", 7 | url="https://github.com/nicktrandafil/tags", 8 | author="", 9 | author_email="", 10 | license="MIT", 11 | packages=["EverloadTags"], 12 | include_package_data=True, 13 | # libraries= 14 | zip_safe=False, 15 | ) 16 | -------------------------------------------------------------------------------- /test/app/form.h: -------------------------------------------------------------------------------- 1 | #ifndef FORM_H 2 | #define FORM_H 3 | 4 | #include 5 | #include 6 | 7 | namespace Ui { 8 | class Form; 9 | } 10 | 11 | class Form : public QWidget { 12 | Q_OBJECT 13 | 14 | public: 15 | explicit Form(QWidget* parent = nullptr); 16 | ~Form(); 17 | 18 | void closeEvent(QCloseEvent* e) override; 19 | 20 | private: 21 | Ui::Form* ui; 22 | }; 23 | 24 | #endif // FORM_H 25 | -------------------------------------------------------------------------------- /py/bindings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /py/EverloadTags/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ctypes 3 | 4 | # Get the directory of this file 5 | module_dir = os.path.dirname(__file__) 6 | 7 | # Construct the full path to the native library 8 | lib_path = os.path.join( 9 | module_dir, "lib", "./libeverload_tags.so" 10 | ) # adapt extension for platform 11 | 12 | # Load the shared library 13 | mylib = ctypes.CDLL(lib_path) 14 | 15 | from .lib.EverloadTags import * 16 | 17 | StyleConfig = everload_tags.StyleConfig 18 | BehaviorConfig = everload_tags.BehaviorConfig 19 | Config = everload_tags.Config 20 | TagsLineEdit = everload_tags.TagsLineEdit 21 | TagsEdit = everload_tags.TagsEdit 22 | -------------------------------------------------------------------------------- /src/everload_tags/config.cpp: -------------------------------------------------------------------------------- 1 | #include "everload_tags/config.hpp" 2 | 3 | #include "common.hpp" 4 | 5 | namespace everload_tags { 6 | 7 | void StyleConfig::calcRects(QPoint& lt, std::vector& tags, QFontMetrics const& fm, std::optional const& fit, 8 | bool has_cross) const { 9 | Style::calcRects(lt, tags, *this, fm, fit, has_cross); 10 | } 11 | 12 | void StyleConfig::drawTags(QPainter& p, std::vector const& tags, QFontMetrics const& fm, QPoint const& offset, 13 | bool has_cross) const { 14 | Style::drawTags(p, tags, *this, fm, offset, has_cross); 15 | } 16 | 17 | } // namespace everload_tags 18 | -------------------------------------------------------------------------------- /src/everload_tags/util.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | namespace everload_tags { 12 | 13 | inline void removeDuplicates(std::vector& tags) { 14 | std::unordered_map unique; 15 | for (auto const i : std::views::iota(size_t{0}, tags.size())) { 16 | unique.emplace(tags[i].text, i); 17 | } 18 | 19 | for (auto b = tags.rbegin(), it = b, e = tags.rend(); it != e;) { 20 | if (auto const i = static_cast(std::distance(it, e) - 1); unique.at(it->text) != i) { 21 | tags.erase(it++.base() - 1); 22 | } else { 23 | ++it; 24 | } 25 | } 26 | } 27 | 28 | } // namespace everload_tags 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/nicktrandafil/tags/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nicktrandafil/tags/actions/workflows/ci.yml) 2 | [![Pybindings](https://github.com/nicktrandafil/tags/actions/workflows/ci_pybindings.yml/badge.svg?branch=main)](https://github.com/nicktrandafil/tags/actions/workflows/ci_pybindings.yml) 3 | 4 | # tags 5 | 6 | A widget (Qt5/Qt6) for tag editing 7 | 8 | 9 | 10 | Python instructions: 11 | 12 | ```bash 13 | mkdir build 14 | cmake . -B build -G Ninja \ 15 | -DCMAKE_BUILD_TYPE=Release \ 16 | -DCMAKE_INSTALL_PREFIX=$(pwd)/py/EverloadTags 17 | ninja -C build/ 18 | ninja -C build/ install 19 | cd py 20 | python demo.py 21 | ``` 22 | 23 | You can either: 24 | Use pip to add EverloadTags to your site-packages 25 | 26 | ```bash 27 | pip install ./py 28 | ``` 29 | 30 | Copy the py/EverloadTags folder to your python project and import see demo.py 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nicolai Trandafil 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 all 13 | 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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-24.04 12 | 13 | strategy: 14 | matrix: 15 | build_type: 16 | - Debug 17 | - Release 18 | compiler: 19 | - cxx: g++ 20 | cc: gcc 21 | - cxx: clang++ 22 | cc: clang 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Install dependencies 28 | run: | 29 | sudo apt-get update 30 | sudo apt-get install -y ninja-build catch2 qt6-base-dev 31 | 32 | - name: Configure CMake 33 | env: 34 | CC: ${{matrix.compiler.cc}} 35 | CXX: ${{matrix.compiler.cxx}} 36 | run: | 37 | cmake . -B build -G Ninja \ 38 | -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} \ 39 | -Deverload_tags_TEST=ON \ 40 | -Deverload_tags_BUILD_TESTING_APP=ON 41 | 42 | - name: Build 43 | run: ninja -C build 44 | 45 | - name: Run tests 46 | run: ./build/test_everload_tags 47 | 48 | - name: Test installation 49 | run: | 50 | cmake --install build --prefix install_test 51 | ls -la install_test/ 52 | -------------------------------------------------------------------------------- /py/demo.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PySide6.QtGui import QColor 3 | from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout 4 | 5 | import EverloadTags as ET 6 | 7 | 8 | class MainWindow(QWidget): 9 | def __init__(self): 10 | super().__init__() 11 | self.setWindowTitle("EverloadTags Demo") 12 | 13 | self.config = ET.Config() 14 | self.config.behavior.restore_cursor_position_on_focus_click = True 15 | self.config.style.color = QColor(255, 0, 255, 255) 16 | self.tags = ["tag1", "tag2", "tag3", "tag4", "tag5"] 17 | self.edit_tags = ["edit1", "edit2", "edit3"] 18 | self.complete_tags = [ 19 | "complete1", 20 | "complete2", 21 | "complete3", 22 | "complete4", 23 | "complete5", 24 | "complete6", 25 | ] 26 | 27 | self.main_layout = QVBoxLayout() 28 | self.tag_line_edit = ET.TagsLineEdit(config=self.config) 29 | self.tag_line_edit.tags(self.tags) 30 | self.tag_line_edit.completion(self.complete_tags) 31 | self.main_layout.addWidget(self.tag_line_edit) 32 | self.tag_edit = ET.TagsEdit() 33 | self.tag_edit.tags(self.edit_tags) 34 | self.tag_edit.completion(self.complete_tags) 35 | self.main_layout.addWidget(self.tag_edit) 36 | self.setLayout(self.main_layout) 37 | self.setMinimumSize(320, 240) 38 | 39 | 40 | if __name__ == "__main__": 41 | app = QApplication(sys.argv) 42 | window = MainWindow() 43 | window.show() 44 | sys.exit(app.exec()) 45 | -------------------------------------------------------------------------------- /test/util.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2025 Nicolai Trandafil 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #include 26 | #include 27 | 28 | using namespace std; 29 | using namespace everload_tags; 30 | 31 | TEST_CASE("removeDuplicates") { 32 | vector tags{Tag{"1", {}}, Tag{"2", {}}, Tag{"1", {}}, Tag{"2", {}}}; 33 | removeDuplicates(tags); 34 | REQUIRE(tags == vector{Tag{"1", {}}, Tag{"2", {}}}); 35 | } 36 | 37 | TEST_CASE("removeDuplicates does nothing") { 38 | vector tags{Tag{"1", {}}, Tag{"2", {}}}; 39 | removeDuplicates(tags); 40 | REQUIRE(tags == vector{Tag{"1", {}}, Tag{"2", {}}}); 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/ci_pybindings.yml: -------------------------------------------------------------------------------- 1 | name: Pybindings 2 | 3 | on: 4 | push: 5 | branches: [ main, dev ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-24.04 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install Qt 17 | uses: jurplel/install-qt-action@v4 18 | with: 19 | version: ${{ '6.6.1' }} 20 | host: 'linux' 21 | target: 'desktop' 22 | arch: 'gcc_64' 23 | 24 | - name: Install dependencies 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y ninja-build clang-19 llvm-19-dev 28 | 29 | - name: Set a default clang compiler 30 | run: | 31 | sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100 32 | sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100 33 | sudo update-alternatives --install /usr/bin/llvm-config llvm-config /usr/bin/llvm-config-19 100 34 | 35 | - name: Prepare Python virtualenv 36 | run: | 37 | python -m venv venv 38 | source venv/bin/activate 39 | pip install PySide6==6.6.1 PySide6_Addons==6.6.1 PySide6_Essentials==6.6.1 shiboken6==6.6.1 shiboken6_generator==6.6.1 40 | 41 | - name: Configure CMake 42 | run: | 43 | source venv/bin/activate 44 | cmake . -B build -G Ninja \ 45 | -DCMAKE_BUILD_TYPE=Release \ 46 | -Deverload_tags_TEST=OFF \ 47 | -Deverload_tags_BUILD_TESTING_APP=ON \ 48 | -Deverload_tags_BUILD_PYTHON_BINDINGS=ON 49 | 50 | - name: Build 51 | run: | 52 | source venv/bin/activate 53 | ninja -C build 54 | 55 | - name: Install 56 | run: | 57 | source venv/bin/activate 58 | cmake --install build --prefix py/EverloadTags 59 | 60 | - name: Run 61 | run: | 62 | source venv/bin/activate 63 | export QT_QPA_PLATFORM=offscreen 64 | timeout 3s env LD_LIBRARY_PATH=$VIRTUAL_ENV/lib/python3.13/site-packages/PySide6 python py/demo.py || rc=$? 65 | if [[ $rc -ne 124 ]]; then exit 1; fi 66 | -------------------------------------------------------------------------------- /py/EverloadTags/__init__.pyi: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import QMargins, Signal 2 | from PySide6.QtGui import QColor 3 | from PySide6.QtWidgets import QWidget, QAbstractScrollArea 4 | import typing 5 | 6 | class BehaviorConfig: 7 | unique: bool 8 | restore_cursor_position_on_focus_click: bool 9 | 10 | class StyleConfig: 11 | # Padding from the text to the the pill border 12 | pill_thickness: QMargins = QMargins(7, 7, 8, 7) 13 | 14 | # Space between pills 15 | pills_h_spacing: int = 7 16 | 17 | # Size of cross side 18 | tag_cross_size: int = 8 19 | 20 | # Distance between text and the cross 21 | tag_cross_spacing: int = 3 22 | 23 | color = QColor(255, 164, 100, 100) 24 | 25 | # Rounding of the pill 26 | rounding_x_radius: int = 5 27 | 28 | # Rounding of the pill 29 | rounding_y_radius: int = 5 30 | 31 | class Config: 32 | style: StyleConfig 33 | behavior: BehaviorConfig 34 | 35 | class TagsLineEdit(QWidget): 36 | tagsEdited: typing.ClassVar[Signal] = ... 37 | def __init__( 38 | self, parent: QWidget | None = ..., config: Config | None = ... 39 | ) -> None: ... 40 | def completion(self, completions: list[str]) -> None: ... # Set completions 41 | @typing.overload 42 | def tags(self, tags: list[str]) -> None: ... # Set tags 43 | @typing.overload 44 | def tags(self) -> list[str]: ... # Get tags 45 | @typing.overload 46 | def config(self, config: Config) -> None: ... # Set config 47 | @typing.overload 48 | def config(self) -> Config: ... # Get config 49 | 50 | class TagsEdit(QAbstractScrollArea): 51 | tagsEdited: typing.ClassVar[Signal] = ... 52 | def __init__( 53 | self, parent: QWidget | None = ..., config: Config | None = ... 54 | ) -> None: ... 55 | def completion(self, completions: list[str]) -> None: ... # Set completions 56 | @typing.overload 57 | def tags(self, tags: list[str]) -> None: ... # Set tags 58 | @typing.overload 59 | def tags(self) -> list[str]: ... # Get tags 60 | @typing.overload 61 | def config(self, config: Config) -> None: ... # Set config 62 | @typing.overload 63 | def config(self) -> Config: ... # Get config 64 | -------------------------------------------------------------------------------- /common.cmake: -------------------------------------------------------------------------------- 1 | # Usage: set_target_build_settings( [WARNINGS_ARE_ERRORS ON/OFF]) 2 | function(set_target_build_settings target) 3 | if(NOT TARGET ${target}) 4 | message(FATAL_ERROR "Not target ${target}") 5 | endif() 6 | 7 | cmake_parse_arguments(arg "" "WARNINGS_ARE_ERRORS" "" ${ARGN}) 8 | 9 | if(NOT DEFINED arg_WARNINGS_ARE_ERRORS) 10 | set(arg_WARNINGS_ARE_ERRORS ON) 11 | endif() 12 | 13 | message(STATUS "Setting up build for ${target}") 14 | message(STATUS " WARNINGS_ARE_ERRORS: ${arg_WARNINGS_ARE_ERRORS}") 15 | 16 | set_target_properties(${target} PROPERTIES CXX_STANDARD 20 17 | CXX_STANDARD_REQUIRED ON) 18 | 19 | set(build_options) 20 | set(link_options) 21 | 22 | set(sancov_flags --coverage -fsanitize=address -fsanitize=undefined 23 | -fsanitize=leak -fsanitize-address-use-after-scope) 24 | 25 | set(warning_flags -Wall -Wextra -Wpedantic -ftemplate-backtrace-limit=0) 26 | 27 | if(arg_WARNINGS_ARE_ERRORS) 28 | set(error_flags -Werror) 29 | else() 30 | set(error_flags) 31 | endif() 32 | 33 | if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") 34 | set(build_options ${warning_flags} -fconcepts-diagnostics-depth=2) 35 | if(CMAKE_BUILD_TYPE MATCHES "Release|RelWithDebInfo|MinSizeRel") 36 | set(build_options ${build_options} ${error_flags} -fconcepts) 37 | elseif(CMAKE_BUILD_TYPE STREQUAL "Debug") 38 | set(build_options ${build_options} ${sancov_flags}) 39 | set(link_options ${link_options} ${sancov_flags}) 40 | endif() 41 | elseif(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") 42 | set(build_options ${warning_flags} -Wno-unneeded-internal-declaration) 43 | if(CMAKE_BUILD_TYPE MATCHES "Release|RelWithDebInfo|MinSizeRel") 44 | set(build_options ${build_options} ${error_flags}) 45 | elseif(CMAKE_BUILD_TYPE STREQUAL "Debug") 46 | set(build_options ${build_options} ${sancov_flags}) 47 | set(link_options ${link_options} ${sancov_flags}) 48 | endif() 49 | elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") 50 | if(CMAKE_BUILD_TYPE MATCHES "Release|RelWithDebInfo|MinSizeRel") 51 | set(build_options ${build_options} /W4 /WX) 52 | elseif(CMAKE_BUILD_TYPE STREQUAL "Debug") 53 | set(build_options ${build_options} /W4 /WX) 54 | endif() 55 | endif() 56 | 57 | target_compile_options(${target} PRIVATE ${build_options}) 58 | target_link_options(${target} PRIVATE ${link_options}) 59 | endfunction() 60 | -------------------------------------------------------------------------------- /src/everload_tags/scope_exit.hpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2025 Nicolai Trandafil 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include 28 | 29 | #include 30 | #include 31 | 32 | namespace everload_tags { 33 | 34 | template 35 | struct ScopeExit : Fn { 36 | ~ScopeExit() { 37 | try { 38 | (*this)(); 39 | } catch (...) { 40 | qDebug() << "exception durring scope exit"; 41 | } 42 | } 43 | }; 44 | 45 | struct MakeScopeExit { 46 | template 47 | auto operator->*(Fn&& fn) const { 48 | return ScopeExit{std::forward(fn)}; 49 | } 50 | }; 51 | 52 | template 53 | struct ScopeFail : Fn { 54 | ~ScopeFail() { 55 | if (std::current_exception()) { 56 | try { 57 | (*this)(); 58 | } catch (...) { 59 | qDebug() << "exception during scope fail"; 60 | } 61 | } 62 | } 63 | }; 64 | 65 | struct MakeScopeFail { 66 | template 67 | auto operator->*(Fn&& fn) const { 68 | return ScopeFail{std::forward(fn)}; 69 | } 70 | }; 71 | 72 | } // namespace everload_tags 73 | 74 | #define EVERLOAD_TAGS_CONCATENATE_IMPL(s1, s2) s1##s2 75 | 76 | #define EVERLOAD_TAGS_CONCATENATE(s1, s2) EVERLOAD_TAGS_CONCATENATE_IMPL(s1, s2) 77 | 78 | #define EVERLOAD_TAGS_UNIQUE_IDENTIFIER EVERLOAD_TAGS_CONCATENATE(UNIQUE_IDENTIFIER_, __LINE__) 79 | 80 | #define EVERLOAD_TAGS_SCOPE_EXIT auto const EVERLOAD_TAGS_UNIQUE_IDENTIFIER = everload_tags::MakeScopeExit{}->*[&] 81 | 82 | #define EVERLOAD_TAGS_SCOPE_FAIL auto const EVERLOAD_TAGS_UNIQUE_IDENTIFIER = everload_tags::MakeScopeFail{}->*[&] 83 | -------------------------------------------------------------------------------- /include/everload_tags/tags_line_edit.hpp: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2019 Nicolai Trandafil 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include "config.hpp" 28 | 29 | #include 30 | 31 | #include 32 | #include 33 | 34 | namespace everload_tags { 35 | 36 | /// Single line tag editor widget, simial to `QLineEdit`. 37 | /// `Space` commits a tag and initiates a new tag edition. 38 | class TagsLineEdit : public QWidget { 39 | Q_OBJECT 40 | 41 | public: 42 | explicit TagsLineEdit(QWidget* parent = nullptr, Config config = {}); 43 | ~TagsLineEdit() override; 44 | 45 | // QWidget 46 | QSize sizeHint() const override; 47 | QSize minimumSizeHint() const override; 48 | 49 | /// Set completions 50 | void completion(std::vector const& completions); 51 | 52 | /// Set tags 53 | void tags(std::vector const& tags); 54 | 55 | /// Get tags 56 | std::vector tags() const; 57 | 58 | /// Set config 59 | void config(Config config); 60 | 61 | /// Get config 62 | Config config() const; 63 | 64 | signals: 65 | void tagsEdited(); 66 | 67 | protected: 68 | // QWidget 69 | void paintEvent(QPaintEvent* event) override; 70 | void timerEvent(QTimerEvent* event) override; 71 | void mousePressEvent(QMouseEvent* event) override; 72 | void resizeEvent(QResizeEvent* event) override; 73 | void focusInEvent(QFocusEvent* event) override; 74 | void focusOutEvent(QFocusEvent* event) override; 75 | void keyPressEvent(QKeyEvent* event) override; 76 | void mouseMoveEvent(QMouseEvent* event) override; 77 | void wheelEvent(QWheelEvent* event) override; 78 | 79 | private: 80 | struct Impl; 81 | std::unique_ptr impl; 82 | }; 83 | 84 | } // namespace everload_tags 85 | -------------------------------------------------------------------------------- /include/everload_tags/tags_edit.hpp: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2019 Nicolai Trandafil 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include "config.hpp" 28 | 29 | #include 30 | 31 | #include 32 | #include 33 | 34 | namespace everload_tags { 35 | 36 | /// Tag multi-line tags editor widget, similar to `QTextEdit`. 37 | /// `Space` commits a tag and initiates a new tag edition. 38 | class TagsEdit : public QAbstractScrollArea { 39 | Q_OBJECT 40 | 41 | public: 42 | /// \param unique Ensure tags uniqueness 43 | explicit TagsEdit(QWidget* parent = nullptr, Config config = {}); 44 | ~TagsEdit() override; 45 | 46 | // QWidget 47 | QSize sizeHint() const override; 48 | QSize minimumSizeHint() const override; 49 | int heightForWidth(int w) const override; 50 | 51 | /// Set completions 52 | void completion(std::vector const& completions); 53 | 54 | /// Set tags 55 | void tags(std::vector const& tags); 56 | 57 | /// Get tags 58 | std::vector tags() const; 59 | 60 | /// Set config 61 | void config(Config config); 62 | 63 | /// Get config 64 | Config config() const; 65 | 66 | signals: 67 | void tagsEdited(); 68 | 69 | protected: 70 | // QWidget 71 | void paintEvent(QPaintEvent* event) override; 72 | void timerEvent(QTimerEvent* event) override; 73 | void mousePressEvent(QMouseEvent* event) override; 74 | void resizeEvent(QResizeEvent* event) override; 75 | void focusInEvent(QFocusEvent* event) override; 76 | void focusOutEvent(QFocusEvent* event) override; 77 | void keyPressEvent(QKeyEvent* event) override; 78 | void mouseMoveEvent(QMouseEvent* event) override; 79 | 80 | private: 81 | struct Impl; 82 | std::unique_ptr impl; 83 | }; 84 | 85 | } // namespace everload_tags 86 | -------------------------------------------------------------------------------- /test/app/form.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 409 10 | 626 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | QLineEdit: 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | TagsLineEdit: 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | TagsLineEdit (read only): 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | TagsLineEdit (custom style): 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | TagsEdit (restore cursor pos|non-unique): 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | TagsEdit (custom style): 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | TagsEdit (read only) 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | everload_tags::TagsLineEdit 92 | QWidget 93 |
everload_tags/tags_line_edit.hpp
94 | 1 95 |
96 | 97 | everload_tags::TagsEdit 98 | QWidget 99 |
everload_tags/tags_edit.hpp
100 | 1 101 |
102 |
103 | 104 | 105 |
106 | -------------------------------------------------------------------------------- /include/everload_tags/config.hpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2025 Nicolai Trandafil 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | 34 | #include 35 | #include 36 | 37 | namespace everload_tags { 38 | 39 | struct Tag { 40 | QString text; 41 | QRect rect; 42 | 43 | bool operator==(Tag const& rhs) const { 44 | return text == rhs.text && rect == rhs.rect; 45 | } 46 | }; 47 | 48 | struct StyleConfig { 49 | /// Padding from the text to the the pill border 50 | QMargins pill_thickness = {7, 7, 8, 7}; 51 | 52 | /// Space between pills 53 | int pills_h_spacing = 7; 54 | 55 | /// Space between rows of pills (for multi line tags) 56 | int tag_v_spacing = 2; 57 | 58 | /// Size of cross side 59 | qreal tag_cross_size = 8; 60 | 61 | /// Distance between text and the cross 62 | int tag_cross_spacing = 3; 63 | 64 | QColor color{255, 164, 100, 100}; 65 | 66 | /// Rounding of the pill 67 | qreal rounding_x_radius = 5; 68 | 69 | /// Rounding of the pill 70 | qreal rounding_y_radius = 5; 71 | 72 | /// Calculate the width that a tag would have with the given text width 73 | int pillWidth(int text_width, bool has_cross) const { 74 | return text_width + pill_thickness.left() + (has_cross ? (tag_cross_spacing + tag_cross_size) : 0) + 75 | pill_thickness.right(); 76 | } 77 | 78 | /// Calculate the height that a tag would have with the given text height 79 | int pillHeight(int text_height) const { 80 | return text_height + pill_thickness.top() + pill_thickness.bottom(); 81 | } 82 | 83 | /// \param fit When nullopt arranges the tags in a line 84 | void calcRects(QPoint& lt, std::vector& tags, QFontMetrics const& fm, std::optional const& fit, 85 | bool has_cross) const; 86 | 87 | void drawTags(QPainter& p, std::vector const& tags, QFontMetrics const& fm, QPoint const& translate, 88 | bool has_cross) const; 89 | }; 90 | 91 | struct BehaviorConfig { 92 | /// Maintain only unique tags 93 | bool unique = true; 94 | bool restore_cursor_position_on_focus_click = false; 95 | bool read_only = false; 96 | }; 97 | 98 | struct Config { 99 | StyleConfig style{}; 100 | BehaviorConfig behavior{}; 101 | }; 102 | 103 | } // namespace everload_tags 104 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | Language: Cpp 2 | AccessModifierOffset: -4 3 | AlignAfterOpenBracket: Align 4 | AlignConsecutiveAssignments: false 5 | AlignConsecutiveDeclarations: false 6 | AlignEscapedNewlines: Right 7 | AlignOperands: true 8 | AlignTrailingComments: true 9 | AllowAllParametersOfDeclarationOnNextLine: false 10 | AllowShortBlocksOnASingleLine: Empty 11 | AllowShortCaseLabelsOnASingleLine: false 12 | AllowShortFunctionsOnASingleLine: Empty 13 | AllowShortIfStatementsOnASingleLine: false 14 | AllowShortLoopsOnASingleLine: false 15 | AlwaysBreakAfterDefinitionReturnType: None 16 | AlwaysBreakAfterReturnType: None 17 | AlwaysBreakBeforeMultilineStrings: false 18 | AlwaysBreakTemplateDeclarations: true 19 | BinPackArguments: true 20 | BinPackParameters: true 21 | BraceWrapping: 22 | AfterClass: false 23 | AfterControlStatement: false 24 | AfterEnum: false 25 | AfterFunction: false 26 | AfterNamespace: false 27 | AfterObjCDeclaration: false 28 | AfterStruct: false 29 | AfterUnion: false 30 | AfterExternBlock: false 31 | BeforeCatch: false 32 | BeforeElse: false 33 | IndentBraces: false 34 | SplitEmptyFunction: true 35 | SplitEmptyRecord: true 36 | SplitEmptyNamespace: true 37 | BreakBeforeBinaryOperators: None 38 | BreakBeforeBraces: Attach 39 | BreakBeforeInheritanceComma: false 40 | BreakInheritanceList: BeforeColon 41 | BreakBeforeTernaryOperators: true 42 | BreakConstructorInitializersBeforeComma: false 43 | BreakConstructorInitializers: BeforeColon 44 | BreakAfterJavaFieldAnnotations: false 45 | BreakStringLiterals: true 46 | ColumnLimit: 120 47 | CommentPragmas: '^ IWYU pragma:' 48 | CompactNamespaces: false 49 | ConstructorInitializerAllOnOneLineOrOnePerLine: false 50 | ConstructorInitializerIndentWidth: 4 51 | ContinuationIndentWidth: 4 52 | Cpp11BracedListStyle: true 53 | DerivePointerAlignment: false 54 | DisableFormat: false 55 | ExperimentalAutoDetectBinPacking: false 56 | FixNamespaceComments: true 57 | IncludeBlocks: Regroup 58 | IncludeCategories: 59 | # Local 60 | - Regex: '^".+"$' 61 | Priority: 1 62 | # Interface 63 | - Regex: '^$' 64 | Priority: 2 65 | # Qt 66 | - Regex: '^ 6 | #include 7 | #include 8 | #include 9 | 10 | constexpr auto line_tags = "line edit tags"; 11 | constexpr auto box_tags = "box edit tags"; 12 | constexpr auto line_tags2 = "line edit tags 2"; 13 | constexpr auto box_tags2 = "box edit tags 2"; 14 | 15 | using namespace everload_tags; 16 | using namespace std; 17 | 18 | struct MyWidget : QWidget { 19 | std::vector tags; 20 | StyleConfig style{}; 21 | 22 | MyWidget(QWidget* parent) : QWidget{parent} {} 23 | 24 | void paintEvent(QPaintEvent* e) override { 25 | QWidget::paintEvent(e); 26 | QPainter p(this); 27 | 28 | QPoint lt{}; 29 | style.calcRects(lt, tags, fontMetrics(), rect(), false); 30 | 31 | style.drawTags(p, tags, fontMetrics(), {}, false); 32 | } 33 | 34 | QSize minimumSizeHint() const override { 35 | return QSize{40, style.pillHeight(this->fontMetrics().height())}; 36 | } 37 | }; 38 | 39 | Form::Form(QWidget* parent) : QWidget(parent), ui(new Ui::Form) { 40 | ui->setupUi(this); 41 | 42 | StyleConfig style{ 43 | .pill_thickness = {7, 7, 8, 7}, 44 | .pills_h_spacing = 7, 45 | .tag_cross_size = 8, 46 | .tag_cross_spacing = 3, 47 | .color = {255, 7, 100, 100}, 48 | .rounding_x_radius = 5, 49 | .rounding_y_radius = 10, 50 | }; 51 | 52 | BehaviorConfig behavior{ 53 | .unique = false, 54 | .restore_cursor_position_on_focus_click = true, 55 | }; 56 | 57 | QSettings settings; 58 | 59 | { 60 | auto const tags = settings.value(line_tags).value>(); 61 | ui->le->tags(vector{tags.begin(), tags.end()}); 62 | } 63 | 64 | { 65 | auto const tags = settings.value(line_tags).value>(); 66 | ui->ro->config(Config{.behavior = BehaviorConfig{.read_only = true}}); 67 | ui->ro->tags(vector{tags.begin(), tags.end()}); 68 | } 69 | 70 | { 71 | auto const tags = settings.value(line_tags2).value>(); 72 | ui->tl_custom_style->tags(vector{tags.begin(), tags.end()}); 73 | ui->tl_custom_style->config(Config{.style = style}); 74 | } 75 | 76 | { 77 | auto const tags = settings.value(box_tags).value>(); 78 | ui->te->tags(vector{tags.begin(), tags.end()}); 79 | ui->te->config(Config{.behavior = behavior}); 80 | } 81 | 82 | { 83 | auto const tags = settings.value(box_tags2).value>(); 84 | ui->te_custom_style->tags(vector{tags.begin(), tags.end()}); 85 | ui->te_custom_style->config(Config{.style = style}); 86 | 87 | auto widget_5 = new MyWidget(this); 88 | ranges::transform(tags, back_inserter(widget_5->tags), 89 | [](auto const& str) { return Tag{.text = str, .rect = {}}; }); 90 | ui->verticalLayout->addWidget(new QLabel{"MyWidget (uses calcRects() and drawTags()):"}); 91 | ui->verticalLayout->addWidget(widget_5); 92 | } 93 | 94 | { 95 | auto const tags = settings.value(box_tags2).value>(); 96 | ui->te_ro->tags(vector{tags.begin(), tags.end()}); 97 | ui->te_ro->config(Config{.behavior = BehaviorConfig{.read_only = true}}); 98 | } 99 | } 100 | 101 | Form::~Form() { 102 | delete ui; 103 | } 104 | 105 | void Form::closeEvent(QCloseEvent* e) { 106 | QWidget::closeEvent(e); 107 | QSettings settings; 108 | 109 | { 110 | auto const tags = ui->le->tags(); 111 | settings.setValue(line_tags, QVector(tags.begin(), tags.end())); 112 | } 113 | 114 | { 115 | auto const tags = ui->te->tags(); 116 | settings.setValue(box_tags, QVector(tags.begin(), tags.end())); 117 | } 118 | 119 | { 120 | auto const tags = ui->tl_custom_style->tags(); 121 | settings.setValue(line_tags2, QVector(tags.begin(), tags.end())); 122 | } 123 | 124 | { 125 | auto const tags = ui->te_custom_style->tags(); 126 | settings.setValue(box_tags2, QVector(tags.begin(), tags.end())); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.18) 2 | cmake_policy(VERSION 3.18) 3 | cmake_policy(SET CMP0048 NEW) 4 | cmake_policy(SET CMP0022 NEW) 5 | cmake_policy(SET CMP0071 NEW) 6 | 7 | project(everload_tags VERSION 0.1) 8 | 9 | include(common.cmake) 10 | 11 | set(CMAKE_AUTOMOC ON) 12 | set(CMAKE_AUTORCC ON) 13 | set(CMAKE_AUTOUIC ON) 14 | 15 | # if inside subdirectory 16 | if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) 17 | set(${PROJECT_NAME}_sub OFF) 18 | else() 19 | set(${PROJECT_NAME}_sub ON) 20 | endif() 21 | 22 | # 3rd party 23 | 24 | if(NOT ${PROJECT_NAME}_sub) 25 | find_package( 26 | QT NAMES Qt6 27 | COMPONENTS Widgets 28 | QUIET) 29 | if(NOT ${QT_VERSION_MAJOR} EQUAL 6) 30 | find_package( 31 | QT NAMES Qt5 32 | COMPONENTS Widgets 33 | QUIET) 34 | endif() 35 | message(STATUS "Using Qt${QT_VERSION_MAJOR}") 36 | find_package(Qt${QT_VERSION_MAJOR} REQUIRED QUIET COMPONENTS Widgets) 37 | endif() 38 | 39 | if(NOT QT_VERSION_MAJOR) 40 | message(FATAL_ERROR "QT_VERSION_MAJOR should be either 5 or 6") 41 | endif() 42 | 43 | if(NOT TARGET Qt${QT_VERSION_MAJOR}::Widgets) 44 | message(FATAL_ERROR "Qt${QT_VERSION_MAJOR}::Widgets is required dependency") 45 | endif() 46 | 47 | if(Qt${QT_VERSION_MAJOR}Widgets_VERSION VERSION_LESS 5.12.0) 48 | message(FATAL_ERROR "Minimum supported Qt5 version is 5.12.0") 49 | endif() 50 | 51 | # Build python bindings 52 | option(everload_tags_BUILD_PYTHON_BINDINGS "Build Python Bindings" OFF) 53 | if(everload_tags_BUILD_PYTHON_BINDINGS) 54 | if(QT_VERSION_MAJOR VERSION_LESS 6) 55 | message(WARNING " Qt6 is required for python bindings") 56 | else() 57 | if(NOT ${PROJECT_NAME}_sub) 58 | find_package(Qt6 REQUIRED QUIET COMPONENTS Widgets Gui Core) 59 | endif() 60 | include(py/bindings.cmake) 61 | endif() 62 | endif() 63 | 64 | # Target 65 | 66 | set(${PROJECT_NAME}_sources 67 | include/${PROJECT_NAME}/config.hpp 68 | include/${PROJECT_NAME}/tags_line_edit.hpp 69 | include/${PROJECT_NAME}/tags_edit.hpp 70 | src/${PROJECT_NAME}/tags_edit.cpp 71 | src/${PROJECT_NAME}/tags_line_edit.cpp 72 | src/${PROJECT_NAME}/config.cpp 73 | src/${PROJECT_NAME}/scope_exit.hpp 74 | src/${PROJECT_NAME}/common.hpp 75 | src/${PROJECT_NAME}/util.hpp) 76 | 77 | add_library(${PROJECT_NAME} SHARED ${${PROJECT_NAME}_sources}) 78 | target_include_directories( 79 | ${PROJECT_NAME} 80 | PUBLIC $) 81 | target_include_directories(${PROJECT_NAME} SYSTEM 82 | PUBLIC $) 83 | target_link_libraries(${PROJECT_NAME} PUBLIC Qt${QT_VERSION_MAJOR}::Widgets) 84 | set_target_build_settings(${PROJECT_NAME}) 85 | 86 | # Testing app 87 | 88 | option(everload_tags_BUILD_TESTING_APP "Build testing app" OFF) 89 | if(everload_tags_BUILD_TESTING_APP) 90 | add_executable(app test/app/main.cpp test/app/form.h test/app/form.cpp 91 | test/app/form.ui) 92 | set_target_build_settings(app) 93 | target_link_libraries(app PRIVATE ${PROJECT_NAME}) 94 | endif() 95 | 96 | # Unit tests 97 | option(everload_tags_TEST "Build unit tests" OFF) 98 | if(everload_tags_TEST) 99 | find_package(Catch2 REQUIRED) 100 | add_executable(test_everload_tags test/util.cpp) 101 | target_include_directories(test_everload_tags PRIVATE include src) 102 | target_link_libraries(test_everload_tags PRIVATE Catch2::Catch2WithMain 103 | Qt${QT_VERSION_MAJOR}::Gui) 104 | set_target_build_settings(test_everload_tags) 105 | endif() 106 | 107 | # Setup package config 108 | 109 | install(DIRECTORY include/${PROJECT_NAME} DESTINATION include) 110 | 111 | if(NOT ${PROJECT_NAME}_sub) 112 | include(CMakePackageConfigHelpers) 113 | set(CONFIG_PACKAGE_INSTALL_DIR lib/cmake/${PROJECT_NAME}) 114 | 115 | write_basic_package_version_file( 116 | ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake 117 | COMPATIBILITY SameMajorVersion) 118 | 119 | install( 120 | TARGETS ${PROJECT_NAME} ${bindings_library} 121 | EXPORT ${PROJECT_NAME}-targets 122 | DESTINATION lib) 123 | 124 | install(EXPORT ${PROJECT_NAME}-targets 125 | DESTINATION ${CONFIG_PACKAGE_INSTALL_DIR}) 126 | 127 | configure_file( 128 | ${PROJECT_NAME}-config.cmake.in 129 | ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake @ONLY) 130 | 131 | install( 132 | FILES ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake 133 | ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake 134 | DESTINATION ${CONFIG_PACKAGE_INSTALL_DIR}) 135 | else() 136 | install(TARGETS ${PROJECT_NAME} DESTINATION lib) 137 | endif() 138 | -------------------------------------------------------------------------------- /py/bindings.cmake: -------------------------------------------------------------------------------- 1 | # Shiboken The name of the generated bindings module (as imported in Python). 2 | set(bindings_library "EverloadTags") 3 | 4 | # The header file with all the types and functions for which bindings will be 5 | # generated. 6 | set(wrapped_header ${CMAKE_SOURCE_DIR}/py/bindings.h) 7 | 8 | # The typesystem xml file which defines the relationships between the C++ types 9 | # / functions and the corresponding Python equivalents. 10 | set(typesystem_file ${CMAKE_SOURCE_DIR}/py/bindings.xml) 11 | 12 | # Specify which C++ files will be generated by shiboken. This includes the 13 | # module wrapper and a '.cpp' file per C++ type. These are needed for generating 14 | # the module shared library. 15 | 16 | set(generated_path ${CMAKE_CURRENT_BINARY_DIR}/${bindings_library}) 17 | 18 | set(generated_sources 19 | ${generated_path}/everload_tags_behaviorconfig_wrapper.cpp 20 | ${generated_path}/everload_tags_config_wrapper.cpp 21 | ${generated_path}/everloadtags_module_wrapper.cpp 22 | ${generated_path}/everload_tags_styleconfig_wrapper.cpp 23 | ${generated_path}/everload_tags_tagsedit_wrapper.cpp 24 | ${generated_path}/everload_tags_tagslineedit_wrapper.cpp 25 | ${generated_path}/everload_tags_wrapper.cpp) 26 | # =================== Shiboken detection ====================== 27 | # Use provided python interpreter if given. 28 | if(NOT python_interpreter) 29 | find_program(python_interpreter "python") 30 | if(NOT python_interpreter) 31 | message(FATAL_ERROR "Make sure python is in PATH.") 32 | endif() 33 | endif() 34 | message(STATUS "Using python interpreter: ${python_interpreter}") 35 | 36 | # Macro to get various pyside / python include / link flags and paths. Uses the 37 | # not entirely supported utils/pyside_config.py file. 38 | macro(pyside_config option output_var) 39 | if(${ARGC} GREATER 2) 40 | set(is_list ${ARGV2}) 41 | else() 42 | set(is_list "") 43 | endif() 44 | 45 | execute_process( 46 | COMMAND ${python_interpreter} 47 | "${CMAKE_SOURCE_DIR}/utils/pyside_config.py" ${option} 48 | OUTPUT_VARIABLE ${output_var} 49 | OUTPUT_STRIP_TRAILING_WHITESPACE) 50 | 51 | if("${${output_var}}" STREQUAL "") 52 | message( 53 | FATAL_ERROR "Error: pyside_config.py ${option} returned no output.") 54 | endif() 55 | if(is_list) 56 | string(REPLACE " " ";" ${output_var} "${${output_var}}") 57 | endif() 58 | endmacro() 59 | 60 | # Query for the shiboken generator path, Python path, include paths and linker 61 | # flags. 62 | pyside_config(--shiboken-module-path shiboken_module_path) 63 | pyside_config(--shiboken-generator-path shiboken_generator_path) 64 | pyside_config(--pyside-path pyside_path) 65 | pyside_config(--pyside-include-path pyside_include_dir 1) 66 | pyside_config(--python-include-path python_include_dir) 67 | pyside_config(--shiboken-generator-include-path shiboken_include_dir 1) 68 | pyside_config(--shiboken-module-shared-libraries-cmake 69 | shiboken_shared_libraries 0) 70 | pyside_config(--python-link-flags-cmake python_linking_data 0) 71 | pyside_config(--pyside-shared-libraries-cmake pyside_shared_libraries 0) 72 | 73 | set(shiboken_path 74 | "${shiboken_generator_path}/shiboken6${CMAKE_EXECUTABLE_SUFFIX}") 75 | if(NOT EXISTS ${shiboken_path}) 76 | message( 77 | FATAL_ERROR "Shiboken executable not found at path: ${shiboken_path}") 78 | endif() 79 | 80 | # Enable rpaths so that the built shared libraries find their dependencies. 81 | set(CMAKE_SKIP_BUILD_RPATH FALSE) 82 | set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) 83 | set(CMAKE_INSTALL_RPATH ${shiboken_module_path} ${CMAKE_CURRENT_SOURCE_DIR}) 84 | set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) 85 | 86 | # Get the relevant Qt include dirs, to pass them on to shiboken. 87 | get_property( 88 | QT_WIDGETS_INCLUDE_DIRS 89 | TARGET Qt6::Widgets 90 | PROPERTY INTERFACE_INCLUDE_DIRECTORIES) 91 | get_property( 92 | QT_GUI_INCLUDE_DIRS 93 | TARGET Qt6::Gui 94 | PROPERTY INTERFACE_INCLUDE_DIRECTORIES) 95 | get_property( 96 | QT_CORE_INCLUDE_DIRS 97 | TARGET Qt6::Core 98 | PROPERTY INTERFACE_INCLUDE_DIRECTORIES) 99 | set(INCLUDES "") 100 | foreach(INCLUDE_DIR 101 | ${QT_WIDGETS_INCLUDE_DIRS} ${QT_GUI_INCLUDE_DIRS} 102 | ${QT_CORE_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/include/everload_tags/) 103 | list(APPEND INCLUDES "-I${INCLUDE_DIR}") 104 | endforeach() 105 | 106 | # On macOS, check if Qt is a framework build. This affects how include paths 107 | # should be handled. 108 | get_target_property(QtCore_is_framework Qt6::Core FRAMEWORK) 109 | if(QtCore_is_framework) 110 | get_target_property(qt_core_library_location Qt6::Core LOCATION) 111 | get_filename_component(qt_core_library_location_dir 112 | "${qt_core_library_location}" DIRECTORY) 113 | get_filename_component(lib_dir "${qt_core_library_location_dir}/../" 114 | ABSOLUTE) 115 | list(APPEND INCLUDES "--framework-include-paths=${lib_dir}") 116 | endif() 117 | 118 | # We need to include the headers for the module bindings that we use. 119 | set(pyside_additional_includes "") 120 | foreach(INCLUDE_DIR ${pyside_include_dir}) 121 | list(APPEND pyside_additional_includes "${INCLUDE_DIR}/QtCore") 122 | list(APPEND pyside_additional_includes "${INCLUDE_DIR}/QtGui") 123 | list(APPEND pyside_additional_includes "${INCLUDE_DIR}/QtWidgets") 124 | endforeach() 125 | # ==== Shiboken target for generating binding C++ files ==== 126 | 127 | # Set up the options to pass to shiboken. 128 | set(shiboken_options 129 | --generator-set=shiboken 130 | --enable-parent-ctor-heuristic 131 | --enable-pyside-extensions 132 | --enable-return-value-heuristic 133 | --use-isnull-as-nb_nonzero 134 | --avoid-protected-hack 135 | ${INCLUDES} 136 | -I${CMAKE_SOURCE_DIR} 137 | -T${CMAKE_SOURCE_DIR} 138 | -T${pyside_path}/typesystems 139 | --output-directory=${CMAKE_CURRENT_BINARY_DIR}) 140 | 141 | set(generated_sources_dependencies ${wrapped_header} ${typesystem_file}) 142 | 143 | # Add custom target to run shiboken to generate the binding cpp files. 144 | add_custom_command( 145 | OUTPUT ${generated_sources} 146 | COMMAND ${shiboken_path} ${shiboken_options} ${wrapped_header} 147 | ${typesystem_file} 148 | DEPENDS ${generated_sources_dependencies} 149 | # IMPLICIT_DEPENDS CXX ${wrapped_header} 150 | WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} 151 | COMMENT "Running generator for ${typesystem_file}.") 152 | 153 | # ==== CMake target - bindings_library ==== 154 | 155 | # Set the cpp files which will be used for the bindings library. 156 | set(${bindings_library}_sources ${generated_sources}) 157 | 158 | # Define and build the bindings library. 159 | add_library(${bindings_library} SHARED ${${bindings_library}_sources}) 160 | 161 | # Apply relevant include and link flags. 162 | target_include_directories(${bindings_library} 163 | PRIVATE ${pyside_additional_includes}) 164 | target_include_directories(${bindings_library} PRIVATE ${pyside_include_dir}) 165 | target_include_directories(${bindings_library} PRIVATE ${python_include_dir}) 166 | target_include_directories(${bindings_library} PRIVATE ${shiboken_include_dir}) 167 | target_include_directories(${bindings_library} 168 | PRIVATE ${CMAKE_SOURCE_DIR}/include/everload_tags/) 169 | 170 | target_link_libraries(${bindings_library} PRIVATE Qt6::Widgets) 171 | target_link_libraries(${bindings_library} PRIVATE ${PROJECT_NAME}) 172 | target_link_libraries(${bindings_library} PRIVATE ${pyside_shared_libraries}) 173 | target_link_libraries(${bindings_library} PRIVATE ${shiboken_shared_libraries}) 174 | 175 | # Adjust the name of generated module. 176 | set_property(TARGET ${bindings_library} PROPERTY PREFIX "") 177 | set_property( 178 | TARGET ${bindings_library} 179 | PROPERTY OUTPUT_NAME "${bindings_library}${PYTHON_EXTENSION_SUFFIX}") 180 | -------------------------------------------------------------------------------- /utils/pyside_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2022 The Qt Company Ltd. 2 | # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause 3 | from __future__ import annotations 4 | 5 | import sysconfig 6 | from enum import Enum 7 | import glob 8 | import os 9 | import re 10 | import sys 11 | 12 | 13 | PYSIDE = "pyside6" 14 | PYSIDE_MODULE = "PySide6" 15 | SHIBOKEN = "shiboken6" 16 | 17 | 18 | class Package(Enum): 19 | SHIBOKEN_MODULE = 1 20 | SHIBOKEN_GENERATOR = 2 21 | PYSIDE_MODULE = 3 22 | 23 | 24 | generic_error = ( 25 | "Did you forget to activate your virtualenv? Or perhaps" 26 | f" you forgot to build / install {PYSIDE_MODULE} into your currently active Python" 27 | " environment?" 28 | ) 29 | pyside_error = f"Unable to locate {PYSIDE_MODULE}. {generic_error}" 30 | shiboken_module_error = f"Unable to locate {SHIBOKEN}-module. {generic_error}" 31 | shiboken_generator_error = f"Unable to locate shiboken-generator. {generic_error}" 32 | pyside_libs_error = f"Unable to locate the PySide shared libraries. {generic_error}" 33 | python_link_error = "Unable to locate the Python library for linking." 34 | python_include_error = "Unable to locate the Python include headers directory." 35 | 36 | options = [] 37 | 38 | # option, function, error, description 39 | options.append( 40 | ( 41 | "--shiboken-module-path", 42 | lambda: find_shiboken_module(), 43 | shiboken_module_error, 44 | "Print shiboken module location", 45 | ) 46 | ) 47 | options.append( 48 | ( 49 | "--shiboken-generator-path", 50 | lambda: find_shiboken_generator(), 51 | shiboken_generator_error, 52 | "Print shiboken generator location", 53 | ) 54 | ) 55 | options.append( 56 | ( 57 | "--pyside-path", 58 | lambda: find_pyside(), 59 | pyside_error, 60 | f"Print {PYSIDE_MODULE} location", 61 | ) 62 | ) 63 | 64 | options.append( 65 | ( 66 | "--python-include-path", 67 | lambda: get_python_include_path(), 68 | python_include_error, 69 | "Print Python include path", 70 | ) 71 | ) 72 | options.append( 73 | ( 74 | "--shiboken-generator-include-path", 75 | lambda: get_package_include_path(Package.SHIBOKEN_GENERATOR), 76 | pyside_error, 77 | "Print shiboken generator include paths", 78 | ) 79 | ) 80 | options.append( 81 | ( 82 | "--pyside-include-path", 83 | lambda: get_package_include_path(Package.PYSIDE_MODULE), 84 | pyside_error, 85 | "Print PySide6 include paths", 86 | ) 87 | ) 88 | 89 | options.append( 90 | ( 91 | "--python-link-flags-qmake", 92 | lambda: python_link_flags_qmake(), 93 | python_link_error, 94 | "Print python link flags for qmake", 95 | ) 96 | ) 97 | options.append( 98 | ( 99 | "--python-link-flags-cmake", 100 | lambda: python_link_flags_cmake(), 101 | python_link_error, 102 | "Print python link flags for cmake", 103 | ) 104 | ) 105 | 106 | options.append( 107 | ( 108 | "--shiboken-module-qmake-lflags", 109 | lambda: get_package_qmake_lflags(Package.SHIBOKEN_MODULE), 110 | pyside_error, 111 | "Print shiboken6 shared library link flags for qmake", 112 | ) 113 | ) 114 | options.append( 115 | ( 116 | "--pyside-qmake-lflags", 117 | lambda: get_package_qmake_lflags(Package.PYSIDE_MODULE), 118 | pyside_error, 119 | "Print PySide6 shared library link flags for qmake", 120 | ) 121 | ) 122 | 123 | options.append( 124 | ( 125 | "--shiboken-module-shared-libraries-qmake", 126 | lambda: get_shared_libraries_qmake(Package.SHIBOKEN_MODULE), 127 | pyside_libs_error, 128 | "Print paths of shiboken shared libraries (.so's, .dylib's, .dll's) for qmake", 129 | ) 130 | ) 131 | options.append( 132 | ( 133 | "--shiboken-module-shared-libraries-cmake", 134 | lambda: get_shared_libraries_cmake(Package.SHIBOKEN_MODULE), 135 | pyside_libs_error, 136 | "Print paths of shiboken shared libraries (.so's, .dylib's, .dll's) for cmake", 137 | ) 138 | ) 139 | 140 | options.append( 141 | ( 142 | "--pyside-shared-libraries-qmake", 143 | lambda: get_shared_libraries_qmake(Package.PYSIDE_MODULE), 144 | pyside_libs_error, 145 | "Print paths of f{PYSIDE_MODULE} shared libraries (.so's, .dylib's, .dll's) " 146 | "for qmake", 147 | ) 148 | ) 149 | options.append( 150 | ( 151 | "--pyside-shared-libraries-cmake", 152 | lambda: get_shared_libraries_cmake(Package.PYSIDE_MODULE), 153 | pyside_libs_error, 154 | f"Print paths of {PYSIDE_MODULE} shared libraries (.so's, .dylib's, .dll's) " 155 | "for cmake", 156 | ) 157 | ) 158 | 159 | options_usage = "" 160 | for i, (flag, _, _, description) in enumerate(options): 161 | options_usage += f" {flag:<45} {description}" 162 | if i < len(options) - 1: 163 | options_usage += "\n" 164 | 165 | usage = f""" 166 | Utility to determine include/link options of shiboken/PySide and Python for qmake/CMake projects 167 | that would like to embed or build custom shiboken/PySide bindings. 168 | 169 | Usage: pyside_config.py [option] 170 | Options: 171 | {options_usage} 172 | -a Print all options and their values 173 | --help/-h Print this help 174 | """ 175 | 176 | option = sys.argv[1] if len(sys.argv) == 2 else "-a" 177 | if option == "-h" or option == "--help": 178 | print(usage) 179 | sys.exit(0) 180 | 181 | 182 | def clean_path(path): 183 | return path if sys.platform != "win32" else path.replace("\\", "/") 184 | 185 | 186 | def shared_library_suffix(): 187 | if sys.platform == "win32": 188 | return "lib" 189 | elif sys.platform == "darwin": 190 | return "dylib" 191 | # Linux 192 | else: 193 | return "so.*" 194 | 195 | 196 | def import_suffixes(): 197 | import importlib.machinery 198 | 199 | return importlib.machinery.EXTENSION_SUFFIXES 200 | 201 | 202 | def is_debug(): 203 | debug_suffix = "_d.pyd" if sys.platform == "win32" else "_d.so" 204 | return any([s.endswith(debug_suffix) for s in import_suffixes()]) 205 | 206 | 207 | def shared_library_glob_pattern(): 208 | glob = "*." + shared_library_suffix() 209 | return glob if sys.platform == "win32" else "lib" + glob 210 | 211 | 212 | def filter_shared_libraries(libs_list): 213 | def predicate(lib_name): 214 | basename = os.path.basename(lib_name) 215 | if "shiboken" in basename or "pyside6" in basename: 216 | return True 217 | return False 218 | 219 | result = [lib for lib in libs_list if predicate(lib)] 220 | return result 221 | 222 | 223 | # Return qmake link option for a library file name 224 | def link_option(lib): 225 | # On Linux: 226 | # Since we cannot include symlinks with wheel packages 227 | # we are using an absolute path for the libpyside and libshiboken 228 | # libraries when compiling the project 229 | baseName = os.path.basename(lib) 230 | link = " -l" 231 | if sys.platform in [ 232 | "linux", 233 | "linux2", 234 | ]: # Linux: 'libfoo.so' -> '/absolute/path/libfoo.so' 235 | link = lib 236 | elif sys.platform in ["darwin"]: # Darwin: 'libfoo.so' -> '-lfoo' 237 | link += os.path.splitext(baseName[3:])[0] 238 | else: # Windows: 'libfoo.dll' -> 'libfoo.dll' 239 | link += os.path.splitext(baseName)[0] 240 | return link 241 | 242 | 243 | # Locate PySide6 via sys.path package path. 244 | def find_pyside(): 245 | return find_package_path(PYSIDE_MODULE) 246 | 247 | 248 | def find_shiboken_module(): 249 | return find_package_path(SHIBOKEN) 250 | 251 | 252 | def find_shiboken_generator(): 253 | return find_package_path(f"{SHIBOKEN}_generator") 254 | 255 | 256 | def find_package(which_package): 257 | if which_package == Package.SHIBOKEN_MODULE: 258 | return find_shiboken_module() 259 | if which_package == Package.SHIBOKEN_GENERATOR: 260 | return find_shiboken_generator() 261 | if which_package == Package.PYSIDE_MODULE: 262 | return find_pyside() 263 | return None 264 | 265 | 266 | def find_package_path(dir_name): 267 | for p in sys.path: 268 | if "site-" in p: 269 | package = os.path.join(p, dir_name) 270 | if os.path.exists(package): 271 | return clean_path(os.path.realpath(package)) 272 | return None 273 | 274 | 275 | # Return version as "x.y" (e.g. 3.9, 3.12, etc) 276 | def python_version(): 277 | return str(sys.version_info[0]) + "." + str(sys.version_info[1]) 278 | 279 | 280 | def get_python_include_path(): 281 | if sys.platform == "win32": 282 | return sysconfig.get_path("include") 283 | else: 284 | return sysconfig.get_path("include", scheme="posix_prefix") 285 | 286 | 287 | def python_link_flags_qmake(): 288 | flags = python_link_data() 289 | if sys.platform == "win32": 290 | libdir = flags["libdir"] 291 | # This will add the "~1" shortcut for directories that 292 | # contain white spaces 293 | # e.g.: "Program Files" to "Progra~1" 294 | for d in libdir.split("\\"): 295 | if " " in d: 296 | libdir = libdir.replace(d, d.split(" ")[0][:-1] + "~1") 297 | lib_flags = flags["lib"] 298 | return f"-L{libdir} -l{lib_flags}" 299 | elif sys.platform == "darwin": 300 | libdir = flags["libdir"] 301 | lib_flags = flags["lib"] 302 | return f"-L{libdir} -l{lib_flags}" 303 | else: 304 | # Linux and anything else 305 | libdir = flags["libdir"] 306 | lib_flags = flags["lib"] 307 | return f"-L{libdir} -l{lib_flags}" 308 | 309 | 310 | def python_link_flags_cmake(): 311 | flags = python_link_data() 312 | libdir = flags["libdir"] 313 | lib = re.sub(r".dll$", ".lib", flags["lib"]) 314 | return f"{libdir};{lib}" 315 | 316 | 317 | def python_link_data(): 318 | # @TODO Fix to work with static builds of Python 319 | libdir = sysconfig.get_config_var("LIBDIR") 320 | if libdir is None: 321 | libdir = os.path.abspath( 322 | os.path.join(sysconfig.get_config_var("LIBDEST"), "..", "libs") 323 | ) 324 | version = python_version() 325 | version_no_dots = version.replace(".", "") 326 | 327 | flags = {} 328 | flags["libdir"] = libdir 329 | if sys.platform == "win32": 330 | suffix = "_d" if is_debug() else "" 331 | flags["lib"] = f"python{version_no_dots}{suffix}" 332 | 333 | elif sys.platform == "darwin": 334 | flags["lib"] = f"python{version}" 335 | 336 | # Linux and anything else 337 | else: 338 | flags["lib"] = f"python{version}{sys.abiflags}" 339 | 340 | return flags 341 | 342 | 343 | def get_package_include_path(which_package): 344 | package_path = find_package(which_package) 345 | if package_path is None: 346 | return None 347 | 348 | includes = f"{package_path}/include" 349 | 350 | return includes 351 | 352 | 353 | def get_package_qmake_lflags(which_package): 354 | package_path = find_package(which_package) 355 | if package_path is None: 356 | return None 357 | 358 | link = f"-L{package_path}" 359 | glob_result = glob.glob(os.path.join(package_path, shared_library_glob_pattern())) 360 | for lib in filter_shared_libraries(glob_result): 361 | link += " " 362 | link += link_option(lib) 363 | return link 364 | 365 | 366 | def get_shared_libraries_data(which_package): 367 | package_path = find_package(which_package) 368 | if package_path is None: 369 | return None 370 | 371 | glob_result = glob.glob(os.path.join(package_path, shared_library_glob_pattern())) 372 | filtered_libs = filter_shared_libraries(glob_result) 373 | libs = [] 374 | if sys.platform == "win32": 375 | for lib in filtered_libs: 376 | libs.append(os.path.realpath(lib)) 377 | else: 378 | for lib in filtered_libs: 379 | libs.append(lib) 380 | return libs 381 | 382 | 383 | def get_shared_libraries_qmake(which_package): 384 | libs = get_shared_libraries_data(which_package) 385 | if libs is None: 386 | return None 387 | 388 | if sys.platform == "win32": 389 | if not libs: 390 | return "" 391 | dlls = "" 392 | for lib in libs: 393 | dll = os.path.splitext(lib)[0] + ".dll" 394 | dlls += dll + " " 395 | 396 | return dlls 397 | else: 398 | libs_string = "" 399 | for lib in libs: 400 | libs_string += lib + " " 401 | return libs_string 402 | 403 | 404 | def get_shared_libraries_cmake(which_package): 405 | libs = get_shared_libraries_data(which_package) 406 | result = ";".join(libs) 407 | return result 408 | 409 | 410 | print_all = option == "-a" 411 | for argument, handler, error, _ in options: 412 | if option == argument or print_all: 413 | handler_result = handler() 414 | if handler_result is None: 415 | sys.exit(error) 416 | 417 | line = handler_result 418 | if print_all: 419 | line = f"{argument:<40}: {line}" 420 | print(line) 421 | -------------------------------------------------------------------------------- /src/everload_tags/common.hpp: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2021 Nicolai Trandafil 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | #pragma once 26 | 27 | #include "util.hpp" 28 | 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include 39 | #include 40 | #include 41 | 42 | #include 43 | #include 44 | #include 45 | #include 46 | #include 47 | 48 | #if QT_VERSION < QT_VERSION_CHECK(5, 11, 0) 49 | #define FONT_METRICS_WIDTH(fmt, ...) fmt.width(__VA_ARGS__) 50 | #else 51 | #define FONT_METRICS_WIDTH(fmt, ...) fmt.horizontalAdvance(__VA_ARGS__) 52 | #endif 53 | 54 | namespace everload_tags { 55 | 56 | struct Style : StyleConfig { 57 | static QRectF crossRect(QRectF const& r, qreal cross_size) { 58 | QRectF cross(QPointF{0, 0}, QSizeF{cross_size, cross_size}); 59 | cross.moveCenter(QPointF(r.right() - cross_size, r.center().y())); 60 | return cross; 61 | } 62 | 63 | QRectF crossRect(QRectF const& r) const { 64 | return crossRect(r, tag_cross_size); 65 | } 66 | 67 | template Range> 68 | static void calcRects(QPoint& lt, Range&& tags, StyleConfig const& style, QFontMetrics const& fm, 69 | std::optional const& fit, bool has_cross) { 70 | for (auto& tag : tags) { 71 | auto const text_width = FONT_METRICS_WIDTH(fm, tag.text); 72 | QRect rect(lt, QSize(style.pillWidth(text_width, has_cross), style.pillHeight(fm.height()))); 73 | 74 | if (fit) { 75 | if (fit->right() < rect.right() && // doesn't fit in current line 76 | rect.left() != fit->left() // doesn't occupy entire line already 77 | ) { 78 | rect.moveTo(fit->left(), rect.bottom() + style.tag_v_spacing); 79 | lt = rect.topLeft(); 80 | } 81 | } 82 | 83 | tag.rect = rect; 84 | lt.setX(rect.right() + style.pills_h_spacing); 85 | } 86 | } 87 | 88 | template Range> 89 | void calcRects(QPoint& lt, Range&& tags, QFontMetrics const& fm, std::optional const& fit = std::nullopt, 90 | bool has_cross = true) const { 91 | calcRects(lt, tags, *this, fm, fit, has_cross); 92 | } 93 | 94 | template 95 | static void drawTags(QPainter& p, Range&& tags, StyleConfig const& style, QFontMetrics const& fm, 96 | QPoint const& offset, bool has_cross) { 97 | for (auto const& tag : tags) { 98 | QRect const& i_r = tag.rect.translated(offset); 99 | auto const text_pos = 100 | i_r.topLeft() + QPointF(style.pill_thickness.left(), fm.ascent() + ((i_r.height() - fm.height()) / 2)); 101 | 102 | // draw tag rect 103 | QPainterPath path; 104 | path.addRoundedRect(i_r, style.rounding_x_radius, style.rounding_y_radius); 105 | p.fillPath(path, style.color); 106 | 107 | // draw text 108 | p.drawText(text_pos, tag.text); 109 | 110 | if (has_cross) { 111 | auto const i_cross_r = crossRect(i_r, style.tag_cross_size); 112 | 113 | QPen pen = p.pen(); 114 | pen.setWidth(2); 115 | 116 | p.save(); 117 | p.setPen(pen); 118 | p.setRenderHint(QPainter::Antialiasing); 119 | p.drawLine(QLineF(i_cross_r.topLeft(), i_cross_r.bottomRight())); 120 | p.drawLine(QLineF(i_cross_r.bottomLeft(), i_cross_r.topRight())); 121 | p.restore(); 122 | } 123 | } 124 | } 125 | 126 | template 127 | void drawTags(QPainter& p, Range&& tags, QFontMetrics const& fm, QPoint const& offset, 128 | bool has_cross = true) const { 129 | drawTags(p, tags, *this, fm, offset, has_cross); 130 | } 131 | }; 132 | 133 | struct Behavior : BehaviorConfig { 134 | using BehaviorConfig::unique; /// Turn on/off Invariant-2 135 | }; 136 | 137 | // Invariant-1 no empty tags apart from currently being edited. 138 | // Invariant-2 tags are unique. 139 | // Default-state is one empty tag which is editing. 140 | struct State { 141 | std::vector tags{Tag{}}; 142 | size_t editing_index{0}; 143 | int blink_timer{0}; 144 | bool blink_status{true}; 145 | int cursor{0}; 146 | int select_start{0}; 147 | int select_size{0}; 148 | QTextLayout text_layout; 149 | std::unique_ptr completer{new QCompleter{}}; 150 | std::chrono::steady_clock::time_point focused_at{}; 151 | 152 | QRect const& editorRect() const { 153 | return tags[editing_index].rect; 154 | } 155 | 156 | QString const& editorText() const { 157 | return tags[editing_index].text; 158 | } 159 | 160 | QString& editorText() { 161 | return tags[editing_index].text; 162 | } 163 | 164 | void updateCursorBlinking(QObject* ifce) { 165 | setCursorVisible(blink_timer, ifce); 166 | } 167 | 168 | void updateDisplayText() { 169 | text_layout.clearLayout(); 170 | text_layout.setText(editorText()); 171 | text_layout.beginLayout(); 172 | text_layout.createLine(); 173 | text_layout.endLayout(); 174 | } 175 | 176 | void setCursorVisible(bool visible, QObject* ifce) { 177 | if (blink_timer) { 178 | ifce->killTimer(blink_timer); 179 | blink_timer = 0; 180 | } 181 | 182 | if (visible) { 183 | blink_status = true; 184 | int flashTime = QGuiApplication::styleHints()->cursorFlashTime(); 185 | if (flashTime >= 2) { 186 | blink_timer = ifce->startTimer(flashTime / 2); 187 | } 188 | } else { 189 | blink_status = false; 190 | } 191 | } 192 | 193 | QVector formatting(QPalette const& palette) const { 194 | if (select_size == 0) { 195 | return {}; 196 | } 197 | 198 | QTextLayout::FormatRange selection; 199 | selection.start = select_start; 200 | selection.length = select_size; 201 | selection.format.setBackground(palette.brush(QPalette::Highlight)); 202 | selection.format.setForeground(palette.brush(QPalette::HighlightedText)); 203 | return {selection}; 204 | } 205 | 206 | qreal cursorToX() { 207 | return text_layout.lineAt(0).cursorToX(cursor); 208 | } 209 | 210 | void moveCursor(int pos, bool mark) { 211 | if (mark) { 212 | auto e = select_start + select_size; 213 | int anchor = select_size > 0 && cursor == select_start ? e 214 | : select_size > 0 && cursor == e ? select_start 215 | : cursor; 216 | select_start = qMin(anchor, pos); 217 | select_size = qMax(anchor, pos) - select_start; 218 | } else { 219 | deselectAll(); 220 | } 221 | cursor = pos; 222 | } 223 | 224 | void deselectAll() { 225 | select_start = 0; 226 | select_size = 0; 227 | } 228 | 229 | bool hasSelection() const noexcept { 230 | return select_size > 0; 231 | } 232 | 233 | void selectAll() { 234 | select_start = 0; 235 | select_size = editorText().size(); 236 | } 237 | 238 | void removeSelection() { 239 | assert(select_start + select_size <= editorText().size()); 240 | cursor = select_start; 241 | editorText().remove(cursor, select_size); 242 | deselectAll(); 243 | } 244 | 245 | void removeBackwardOne() { 246 | if (hasSelection()) { 247 | removeSelection(); 248 | } else { 249 | editorText().remove(--cursor, 1); 250 | } 251 | } 252 | 253 | void removeDuplicates() { 254 | everload_tags::removeDuplicates(tags); 255 | auto const it = std::find_if(tags.begin(), tags.end(), [](auto const& x) { 256 | return x.text.isEmpty(); // Thanks to Invariant-1 we can track back the editing_index. 257 | }); 258 | assert(it != tags.end()); 259 | editing_index = static_cast(std::distance(tags.begin(), it)); 260 | } 261 | }; 262 | 263 | struct Common : Style, Behavior, State { 264 | void drawEditor(QPainter& p, QPalette const& palette, QPoint const& offset) const { 265 | auto const& r = editorRect(); 266 | auto const& txt_p = r.topLeft() + QPointF(pill_thickness.left(), pill_thickness.top()); 267 | auto const f = formatting(palette); 268 | text_layout.draw(&p, txt_p - offset, f); 269 | if (blink_status) { 270 | text_layout.drawCursor(&p, txt_p - offset, cursor); 271 | } 272 | } 273 | 274 | bool inCrossArea(size_t tag_index, QPoint const& point, QPoint const& offset) const { 275 | return crossRect(tags[tag_index].rect).adjusted(-1, -1, 1, 1).translated(-offset).contains(point) && 276 | (!cursorVisible() || tag_index != editing_index); 277 | } 278 | 279 | bool isCurrentTagADuplicate() const { 280 | assert(editing_index < tags.size()); 281 | auto const mid = tags.begin() + static_cast(editing_index); 282 | auto const text_eq = [this](auto const& x) { return x.text == editorText(); }; 283 | return std::find_if(tags.begin(), mid, text_eq) != mid || 284 | std::find_if(mid + 1, tags.end(), text_eq) != tags.end(); 285 | } 286 | 287 | /// Makes the tag at `i` currently editing, and ensures Invariant-1 and Invariant-2`. 288 | void setEditorIndex(size_t i) { 289 | assert(i < tags.size()); 290 | if (editorText().isEmpty() || (unique && isCurrentTagADuplicate())) { 291 | tags.erase(std::next(begin(tags), static_cast(editing_index))); 292 | if (editing_index <= i) { // Did we shift `i`? 293 | --i; 294 | } 295 | } 296 | editing_index = i; 297 | } 298 | 299 | // Inserts a new tag at `i`, makes the tag currently editing, and ensures Invariant-1. 300 | void editNewTag(size_t i) { 301 | assert(i <= tags.size()); 302 | tags.insert(begin(tags) + static_cast(i), Tag{}); 303 | if (i <= editing_index) { // Did we shift `editing_index`? 304 | ++editing_index; 305 | } 306 | setEditorIndex(i); 307 | moveCursor(0, false); 308 | } 309 | 310 | void editPreviousTag() { 311 | if (editing_index > 0) { 312 | setEditorIndex(editing_index - 1); 313 | moveCursor(editorText().size(), false); 314 | } 315 | } 316 | 317 | void editNextTag() { 318 | if (editing_index < tags.size() - 1) { 319 | setEditorIndex(editing_index + 1); 320 | moveCursor(0, false); 321 | } 322 | } 323 | 324 | void editTag(size_t i) { 325 | assert(i < tags.size()); 326 | setEditorIndex(i); 327 | moveCursor(editorText().size(), false); 328 | } 329 | 330 | void removeTag(size_t i) { 331 | tags.erase(tags.begin() + static_cast(i)); 332 | if (i <= editing_index) { 333 | --editing_index; 334 | } 335 | } 336 | 337 | void setTags(std::vector const& tags) { 338 | std::unordered_set unique_tags; 339 | std::vector t; 340 | for (auto const& x : tags) { 341 | if (/* Invariant-1 */ !x.isEmpty() && /* Invariant-2 */ (!unique || unique_tags.insert(x).second)) { 342 | t.emplace_back(x, QRect{}); 343 | } 344 | } 345 | this->tags = std::move(t); 346 | this->tags.push_back(Tag{}); 347 | editing_index = this->tags.size() - 1; 348 | moveCursor(0, false); 349 | } 350 | 351 | bool cursorVisible() const { 352 | return !read_only && blink_timer; 353 | } 354 | }; 355 | 356 | // ? 357 | inline constexpr QMargins magic_margins = {2, 2, 2, 2}; 358 | 359 | /// \ref `bool QInputControl::isAcceptableInput(QKeyEvent const* event) const` 360 | inline bool isAcceptableInput(QKeyEvent const& event) { 361 | auto const text = event.text(); 362 | if (text.isEmpty()) { 363 | return false; 364 | } 365 | 366 | auto const c = text.at(0); 367 | 368 | if (c.category() == QChar::Other_Format) { 369 | return true; 370 | } 371 | 372 | if (event.modifiers() == Qt::ControlModifier || event.modifiers() == (Qt::ShiftModifier | Qt::ControlModifier)) { 373 | return false; 374 | } 375 | 376 | if (c.isPrint()) { 377 | return true; 378 | } 379 | 380 | if (c.category() == QChar::Other_PrivateUse) { 381 | return true; 382 | } 383 | 384 | return false; 385 | } 386 | 387 | inline auto elapsed(std::chrono::steady_clock::time_point const& ts) { 388 | return std::chrono::steady_clock::now() - ts; 389 | } 390 | 391 | } // namespace everload_tags 392 | -------------------------------------------------------------------------------- /src/everload_tags/tags_line_edit.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2019 Nicolai Trandafil 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | #include "everload_tags/tags_line_edit.hpp" 26 | 27 | #include "common.hpp" 28 | #include "scope_exit.hpp" 29 | 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include 39 | 40 | #include 41 | #include 42 | 43 | namespace everload_tags { 44 | 45 | struct TagsLineEdit::Impl : Common { 46 | explicit Impl(TagsLineEdit* const& ifce, Config config) 47 | : Common{{config.style}, {config.behavior}, {}}, ifce{ifce} {} 48 | 49 | QPoint offset() const { 50 | return {hscroll, 0}; 51 | } 52 | 53 | using Common::drawTags; 54 | 55 | template 56 | void drawTags(QPainter& p, Range range) const { 57 | drawTags(p, range, ifce->fontMetrics(), -offset(), !read_only); 58 | } 59 | 60 | QRect contentsRect() const { 61 | return ifce->contentsRect() - magic_margins; 62 | } 63 | 64 | using Common::calcRects; 65 | 66 | void calcRects() { 67 | auto const r = contentsRect(); 68 | auto lt = r.topLeft(); 69 | 70 | auto const middle = tags.begin() + static_cast(editing_index); 71 | 72 | calcRects(lt, std::ranges::subrange(tags.begin(), middle), ifce->fontMetrics(), std::nullopt, !read_only); 73 | 74 | if (cursorVisible() || !editorText().isEmpty()) { 75 | calcRects(lt, std::ranges::subrange(middle, middle + 1), ifce->fontMetrics(), std::nullopt, !read_only); 76 | } 77 | 78 | calcRects(lt, std::ranges::subrange(middle + 1, tags.end()), ifce->fontMetrics(), std::nullopt, !read_only); 79 | } 80 | 81 | void setEditorText(QString const& text) { 82 | tags[editing_index].text = text; 83 | moveCursor(editorText().length(), false); 84 | update1(); 85 | } 86 | 87 | void setupCompleter() { 88 | completer->setWidget(ifce); 89 | connect(completer.get(), static_cast(&QCompleter::activated), ifce, 90 | [this](QString const& text) { setEditorText(text); }); 91 | } 92 | 93 | int pillsWidth() const { 94 | if (tags.size() == 1 && tags.front().text.isEmpty()) { 95 | return 0; 96 | } 97 | 98 | int left = tags.front().rect.left(); 99 | int right = tags.back().rect.right(); 100 | 101 | if (editing_index == 0 && !(cursorVisible() || !editorText().isEmpty())) { 102 | left = tags[1].rect.left(); 103 | } else if (editing_index == tags.size() - 1 && !(cursorVisible() || !editorText().isEmpty())) { 104 | right = tags[tags.size() - 2].rect.right(); 105 | } 106 | 107 | return right - left + 1; 108 | } 109 | 110 | void updateHScrollRange() { 111 | auto const contents_rect = contentsRect(); 112 | auto const width_used = pillsWidth(); 113 | 114 | if (contents_rect.width() < width_used) { 115 | hscroll_max = width_used - contents_rect.width(); 116 | } else { 117 | hscroll_max = 0; 118 | } 119 | 120 | hscroll = std::clamp(hscroll, hscroll_min, hscroll_max); 121 | } 122 | 123 | void ensureCursorIsVisible() { 124 | auto const contents_rect = contentsRect().translated(offset()); 125 | int const cursor_x = (editorRect() - pill_thickness).left() + qRound(cursorToX()); 126 | 127 | if (contents_rect.right() < cursor_x) { 128 | hscroll = cursor_x - contents_rect.width(); 129 | } else if (cursor_x < contents_rect.left()) { 130 | hscroll = cursor_x - 1; 131 | } 132 | 133 | hscroll = std::clamp(hscroll, hscroll_min, hscroll_max); 134 | } 135 | 136 | void update1(bool keep_cursor_visible = true) { 137 | updateDisplayText(); 138 | calcRects(); 139 | updateHScrollRange(); 140 | if (keep_cursor_visible) { 141 | ensureCursorIsVisible(); 142 | } 143 | updateCursorBlinking(ifce); 144 | ifce->update(); 145 | } 146 | 147 | void initStyleOption(QStyleOptionFrame* option) const { 148 | assert(option); 149 | option->initFrom(ifce); 150 | option->rect = ifce->contentsRect(); 151 | option->lineWidth = ifce->style()->pixelMetric(QStyle::PM_DefaultFrameWidth, option, ifce); 152 | option->midLineWidth = 0; 153 | option->state |= QStyle::State_Sunken; 154 | option->features = QStyleOptionFrame::None; 155 | } 156 | 157 | TagsLineEdit* const ifce; 158 | 159 | int const hscroll_min = 0; 160 | int hscroll = 0; 161 | int hscroll_max = 0; 162 | }; 163 | 164 | TagsLineEdit::TagsLineEdit(QWidget* parent, Config config) 165 | : QWidget(parent), impl(std::make_unique(this, config)) { 166 | setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); 167 | setFocusPolicy(Qt::StrongFocus); 168 | setCursor(Qt::IBeamCursor); 169 | setAttribute(Qt::WA_InputMethodEnabled, true); 170 | setMouseTracking(true); 171 | 172 | impl->setupCompleter(); 173 | impl->setCursorVisible(hasFocus(), this); 174 | impl->updateDisplayText(); 175 | } 176 | 177 | TagsLineEdit::~TagsLineEdit() = default; 178 | 179 | void TagsLineEdit::resizeEvent(QResizeEvent*) { 180 | impl->calcRects(); 181 | } 182 | 183 | void TagsLineEdit::focusInEvent(QFocusEvent* e) { 184 | QWidget::focusInEvent(e); 185 | impl->focused_at = std::chrono::steady_clock::now(); 186 | impl->setCursorVisible(true, this); 187 | impl->updateDisplayText(); 188 | impl->calcRects(); 189 | impl->updateHScrollRange(); 190 | if (e->reason() != Qt::FocusReason::MouseFocusReason || impl->restore_cursor_position_on_focus_click) { 191 | impl->ensureCursorIsVisible(); 192 | } 193 | update(); 194 | } 195 | 196 | void TagsLineEdit::focusOutEvent(QFocusEvent* e) { 197 | QWidget::focusOutEvent(e); 198 | impl->setCursorVisible(false, this); 199 | impl->updateDisplayText(); 200 | impl->calcRects(); 201 | impl->updateHScrollRange(); 202 | update(); 203 | } 204 | 205 | void TagsLineEdit::paintEvent(QPaintEvent* e) { 206 | QWidget::paintEvent(e); 207 | 208 | QPainter p(this); 209 | 210 | // opt 211 | auto const panel = [this] { 212 | QStyleOptionFrame panel; 213 | impl->initStyleOption(&panel); 214 | return panel; 215 | }(); 216 | 217 | // draw frame 218 | style()->drawPrimitive(QStyle::PE_PanelLineEdit, &panel, &p, this); 219 | 220 | // clip 221 | auto const rect = impl->contentsRect(); 222 | p.setClipRect(rect); 223 | 224 | auto const middle = impl->tags.cbegin() + static_cast(impl->editing_index); 225 | 226 | // tags 227 | impl->drawTags(p, std::ranges::subrange(impl->tags.cbegin(), middle)); 228 | 229 | if (impl->cursorVisible()) { 230 | impl->drawEditor(p, palette(), impl->offset()); 231 | } else if (!impl->editorText().isEmpty()) { 232 | impl->drawTags(p, std::ranges::subrange(middle, middle + 1)); 233 | } 234 | 235 | // tags 236 | impl->drawTags(p, std::ranges::subrange(middle + 1, impl->tags.cend())); 237 | } 238 | 239 | void TagsLineEdit::timerEvent(QTimerEvent* event) { 240 | if (event->timerId() == impl->blink_timer) { 241 | impl->blink_status = !impl->blink_status; 242 | update(); 243 | } 244 | } 245 | 246 | void TagsLineEdit::mousePressEvent(QMouseEvent* event) { 247 | // we don't want to change cursor position if this event is part of focusIn 248 | using namespace std::chrono_literals; 249 | if (impl->read_only || (impl->restore_cursor_position_on_focus_click && elapsed(impl->focused_at) < 1ms)) { 250 | return; 251 | } 252 | 253 | bool keep_cursor_visible = true; 254 | EVERLOAD_TAGS_SCOPE_EXIT { 255 | impl->update1(keep_cursor_visible); 256 | }; 257 | 258 | // remove or edit a tag 259 | for (size_t i = 0; i < impl->tags.size(); ++i) { 260 | if (!impl->tags[i].rect.translated(-impl->offset()).contains(event->pos())) { 261 | continue; 262 | } 263 | 264 | if (impl->inCrossArea(i, event->pos(), impl->offset())) { 265 | impl->removeTag(i); 266 | keep_cursor_visible = false; 267 | } else if (impl->editing_index == i) { 268 | impl->moveCursor( 269 | impl->text_layout.lineAt(0).xToCursor( 270 | (event->pos() - (impl->editorRect() - impl->pill_thickness).translated(-impl->offset()).topLeft()) 271 | .x()), 272 | false); 273 | } else { 274 | impl->editTag(i); 275 | } 276 | 277 | return; 278 | } 279 | 280 | // add new tag closed to the cursor 281 | for (auto it = begin(impl->tags); it != end(impl->tags); ++it) { 282 | // find the closest spot 283 | if (event->pos().x() > it->rect.translated(-impl->offset()).left()) { 284 | continue; 285 | } 286 | 287 | impl->editNewTag(static_cast(std::distance(begin(impl->tags), it))); 288 | return; 289 | } 290 | 291 | // append a new nag 292 | impl->editNewTag(impl->tags.size()); 293 | } 294 | 295 | QSize TagsLineEdit::sizeHint() const { 296 | ensurePolished(); 297 | 298 | auto const fm = fontMetrics(); 299 | QRect rect(0, 0, impl->pillWidth(fm.boundingRect(QLatin1Char('x')).width() * 17, true), 300 | impl->pillHeight(fm.height())); 301 | rect += magic_margins; 302 | 303 | QStyleOptionFrame opt; 304 | impl->initStyleOption(&opt); 305 | 306 | return (style()->sizeFromContents(QStyle::CT_LineEdit, &opt, rect.size(), this)); 307 | } 308 | 309 | QSize TagsLineEdit::minimumSizeHint() const { 310 | ensurePolished(); 311 | 312 | auto const fm = fontMetrics(); 313 | QRect rect(0, 0, impl->pillWidth(fm.maxWidth(), true), impl->pillHeight(fm.height())); 314 | rect += magic_margins; 315 | 316 | QStyleOptionFrame opt; 317 | impl->initStyleOption(&opt); 318 | 319 | return (style()->sizeFromContents(QStyle::CT_LineEdit, &opt, rect.size(), this)); 320 | } 321 | 322 | void TagsLineEdit::keyPressEvent(QKeyEvent* event) { 323 | if (impl->read_only) { 324 | return; 325 | } 326 | 327 | if (event == QKeySequence::SelectAll) { 328 | impl->selectAll(); 329 | } else if (event == QKeySequence::SelectPreviousChar) { 330 | impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true); 331 | } else if (event == QKeySequence::SelectNextChar) { 332 | impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true); 333 | } else { 334 | switch (event->key()) { 335 | case Qt::Key_Left: 336 | if (impl->cursor == 0) { 337 | impl->editPreviousTag(); 338 | } else { 339 | impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false); 340 | } 341 | break; 342 | case Qt::Key_Right: 343 | if (impl->cursor == impl->editorText().size()) { 344 | impl->editNextTag(); 345 | } else { 346 | impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false); 347 | } 348 | break; 349 | case Qt::Key_Home: 350 | if (impl->cursor == 0) { 351 | impl->editTag(0); 352 | } else { 353 | impl->moveCursor(0, false); 354 | } 355 | break; 356 | case Qt::Key_End: 357 | if (impl->cursor == impl->editorText().size()) { 358 | impl->editTag(impl->tags.size() - 1); 359 | } else { 360 | impl->moveCursor(impl->editorText().length(), false); 361 | } 362 | break; 363 | case Qt::Key_Backspace: 364 | if (!impl->editorText().isEmpty()) { 365 | impl->removeBackwardOne(); 366 | } else if (impl->editing_index > 0) { 367 | impl->editPreviousTag(); 368 | } 369 | break; 370 | case Qt::Key_Space: 371 | if (!impl->editorText().isEmpty()) { 372 | impl->editNewTag(impl->editing_index + 1); 373 | } 374 | break; 375 | default: 376 | if (isAcceptableInput(*event)) { 377 | if (impl->hasSelection()) { 378 | impl->removeSelection(); 379 | } 380 | impl->tags[impl->editing_index].text.insert(impl->cursor, event->text()); 381 | impl->cursor += event->text().length(); 382 | break; 383 | } else { 384 | event->setAccepted(false); 385 | return; 386 | } 387 | } 388 | } 389 | 390 | impl->update1(); 391 | 392 | impl->completer->setCompletionPrefix(impl->editorText()); 393 | impl->completer->complete(); 394 | 395 | emit tagsEdited(); 396 | } 397 | 398 | void TagsLineEdit::completion(std::vector const& completions) { 399 | QStringList tmp; 400 | std::copy(begin(completions), end(completions), std::back_inserter(tmp)); 401 | impl->completer = std::make_unique(std::move(tmp)); 402 | impl->setupCompleter(); 403 | } 404 | 405 | void TagsLineEdit::tags(std::vector const& tags) { 406 | impl->setTags(tags); 407 | impl->update1(); 408 | } 409 | 410 | std::vector TagsLineEdit::tags() const { 411 | std::vector ret(impl->tags.size()); 412 | std::transform(impl->tags.begin(), impl->tags.end(), ret.begin(), [](auto const& tag) { return tag.text; }); 413 | if (impl->editorText().isEmpty() || (impl->unique && 1 < std::count(ret.begin(), ret.end(), impl->editorText()))) { 414 | ret.erase(ret.begin() + static_cast(impl->editing_index)); 415 | } 416 | return ret; 417 | } 418 | 419 | void TagsLineEdit::mouseMoveEvent(QMouseEvent* event) { 420 | event->accept(); 421 | for (size_t i = 0; i < impl->tags.size(); ++i) { 422 | if (impl->inCrossArea(i, event->pos(), impl->offset())) { 423 | setCursor(Qt::ArrowCursor); 424 | return; 425 | } 426 | } 427 | setCursor(Qt::IBeamCursor); 428 | } 429 | 430 | void TagsLineEdit::wheelEvent(QWheelEvent* event) { 431 | event->accept(); 432 | impl->calcRects(); 433 | impl->updateHScrollRange(); 434 | impl->hscroll = std::clamp(impl->hscroll - event->pixelDelta().x(), impl->hscroll_min, impl->hscroll_max); 435 | update(); 436 | } 437 | 438 | void TagsLineEdit::config(Config config) { 439 | if (impl->unique && impl->unique != config.behavior.unique) { 440 | impl->removeDuplicates(); 441 | } 442 | static_cast(*impl) = config.style; 443 | static_cast(*impl) = config.behavior; 444 | impl->update1(); 445 | } 446 | 447 | Config TagsLineEdit::config() const { 448 | return Config{ 449 | .style = static_cast(*impl), 450 | .behavior = static_cast(*impl), 451 | }; 452 | } 453 | 454 | } // namespace everload_tags 455 | -------------------------------------------------------------------------------- /src/everload_tags/tags_edit.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2021 Nicolai Trandafil 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | #include "everload_tags/tags_edit.hpp" 26 | 27 | #include "common.hpp" 28 | #include "scope_exit.hpp" 29 | 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include 39 | #include 40 | 41 | #include 42 | 43 | namespace everload_tags { 44 | 45 | struct TagsEdit::Impl : Common { 46 | explicit Impl(TagsEdit* ifce, Config config) : Common{{config.style}, {config.behavior}, {}}, ifce{ifce} {} 47 | 48 | QPoint offset() const { 49 | return QPoint{ifce->horizontalScrollBar()->value(), ifce->verticalScrollBar()->value()}; 50 | } 51 | 52 | using Common::drawTags; 53 | 54 | template 55 | void drawTags(QPainter& p, Range range) const { 56 | drawTags(p, range, ifce->fontMetrics(), -offset(), !read_only); 57 | } 58 | 59 | void setEditorText(QString const& text) { 60 | editorText() = text; 61 | moveCursor(editorText().length(), false); 62 | update1(); 63 | } 64 | 65 | void setupCompleter() { 66 | completer->setWidget(ifce); 67 | QObject::connect(completer.get(), qOverload(&QCompleter::activated), 68 | [this](QString const& text) { setEditorText(text); }); 69 | } 70 | 71 | using Common::calcRects; 72 | 73 | void calcRects(QRect r, QPoint& lt, QFontMetrics const& fm) { 74 | auto const middle = tags.begin() + static_cast(editing_index); 75 | 76 | calcRects(lt, std::ranges::subrange(tags.begin(), middle), fm, r, !read_only); 77 | 78 | if (cursorVisible() || !editorText().isEmpty()) { 79 | calcRects(lt, std::ranges::subrange(middle, middle + 1), fm, r, !read_only); 80 | } 81 | 82 | calcRects(lt, std::ranges::subrange(middle + 1, tags.end()), fm, r, !read_only); 83 | } 84 | 85 | QRect calcRects(QRect r) { 86 | auto lt = r.topLeft(); 87 | auto const fm = ifce->fontMetrics(); 88 | calcRects(r, lt, fm); 89 | r.setBottom(lt.y() + pillHeight(fm.height()) - 1); 90 | return r; 91 | } 92 | 93 | QRect calcRects() { 94 | return calcRects(contentsRect()); 95 | } 96 | 97 | QRect contentsRect() const { 98 | return ifce->viewport()->contentsRect(); 99 | } 100 | 101 | void calcRectsUpdateScrollRanges() { 102 | calcRects(); 103 | updateVScrollRange(); 104 | updateHScrollRange(); 105 | } 106 | 107 | void updateVScrollRange() { 108 | if (tags.size() == 1 && tags.front().text.isEmpty()) { 109 | ifce->verticalScrollBar()->setRange(0, 0); 110 | return; 111 | } 112 | 113 | auto const fm = ifce->fontMetrics(); 114 | auto const row_h = pillHeight(fm.height()) + tag_v_spacing; 115 | ifce->verticalScrollBar()->setPageStep(row_h); 116 | assert(!tags.empty()); // Invariant-1 117 | 118 | int top = tags.front().rect.top(); 119 | int bottom = tags.back().rect.bottom(); 120 | 121 | if (editing_index == 0 && !(cursorVisible() || !editorText().isEmpty())) { 122 | top = tags[1].rect.top(); 123 | } else if (editing_index == tags.size() - 1 && !(cursorVisible() || !editorText().isEmpty())) { 124 | bottom = tags[tags.size() - 2].rect.bottom(); 125 | } 126 | 127 | auto const h = bottom - top + 1; 128 | auto const contents_rect = contentsRect(); 129 | 130 | if (contents_rect.height() < h) { 131 | ifce->verticalScrollBar()->setRange(0, h - contents_rect.height()); 132 | } else { 133 | ifce->verticalScrollBar()->setRange(0, 0); 134 | } 135 | } 136 | 137 | void updateHScrollRange() { 138 | assert(!tags.empty()); // Invariant-1 139 | auto const width = std::max_element(begin(tags), end(tags), [](auto const& x, auto const& y) { 140 | return x.rect.width() < y.rect.width(); 141 | })->rect.width(); 142 | 143 | auto const contents_rect_width = contentsRect().width(); 144 | 145 | if (contents_rect_width < width) { 146 | ifce->horizontalScrollBar()->setRange(0, width - contents_rect_width); 147 | } else { 148 | ifce->horizontalScrollBar()->setRange(0, 0); 149 | } 150 | } 151 | 152 | void ensureCursorIsVisibleV() { 153 | if (!cursorVisible()) { 154 | return; 155 | } 156 | auto const fm = ifce->fontMetrics(); 157 | auto const row_h = pillHeight(fm.height()); 158 | auto const vscroll = ifce->verticalScrollBar()->value(); 159 | auto const cursor_top = editorRect().topLeft() + QPoint(qRound(cursorToX()), 0); 160 | auto const cursor_bottom = cursor_top + QPoint(0, row_h - 1); 161 | auto const contents_rect = contentsRect().translated(0, vscroll); 162 | if (contents_rect.bottom() < cursor_bottom.y()) { 163 | ifce->verticalScrollBar()->setValue(cursor_bottom.y() - row_h); 164 | } else if (cursor_top.y() < contents_rect.top()) { 165 | ifce->verticalScrollBar()->setValue(cursor_top.y() - 1); 166 | } 167 | } 168 | 169 | void ensureCursorIsVisibleH() { 170 | if (!cursorVisible()) { 171 | return; 172 | } 173 | auto const contents_rect = contentsRect().translated(ifce->horizontalScrollBar()->value(), 0); 174 | auto const cursor_x = (editorRect() - pill_thickness).left() + qRound(cursorToX()); 175 | if (contents_rect.right() < cursor_x) { 176 | ifce->horizontalScrollBar()->setValue(cursor_x - contents_rect.width()); 177 | } else if (cursor_x < contents_rect.left()) { 178 | ifce->horizontalScrollBar()->setValue(cursor_x - 1); 179 | } 180 | } 181 | 182 | void update1(bool keep_cursor_visible = true) { 183 | updateDisplayText(); 184 | calcRectsUpdateScrollRanges(); 185 | if (keep_cursor_visible) { 186 | ensureCursorIsVisibleV(); 187 | ensureCursorIsVisibleH(); 188 | } 189 | updateCursorBlinking(ifce); 190 | ifce->viewport()->update(); 191 | } 192 | 193 | TagsEdit* const ifce; 194 | }; 195 | 196 | TagsEdit::TagsEdit(QWidget* parent, Config config) 197 | : QAbstractScrollArea(parent), impl(std::make_unique(this, config)) { 198 | QSizePolicy size_policy(QSizePolicy::Ignored, QSizePolicy::Preferred); 199 | size_policy.setHeightForWidth(true); 200 | setSizePolicy(size_policy); 201 | 202 | setFocusPolicy(Qt::StrongFocus); 203 | viewport()->setCursor(Qt::IBeamCursor); 204 | setAttribute(Qt::WA_InputMethodEnabled, true); 205 | setMouseTracking(true); 206 | setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); 207 | 208 | impl->setupCompleter(); 209 | impl->setCursorVisible(hasFocus(), this); 210 | impl->updateDisplayText(); 211 | 212 | viewport()->setContentsMargins(1, 1, 1, 1); 213 | } 214 | 215 | TagsEdit::~TagsEdit() = default; 216 | 217 | void TagsEdit::resizeEvent(QResizeEvent* event) { 218 | QAbstractScrollArea::resizeEvent(event); 219 | impl->calcRectsUpdateScrollRanges(); 220 | } 221 | 222 | void TagsEdit::focusInEvent(QFocusEvent* event) { 223 | QAbstractScrollArea::focusInEvent(event); 224 | impl->focused_at = std::chrono::steady_clock::now(); 225 | impl->setCursorVisible(true, this); 226 | impl->updateDisplayText(); 227 | impl->calcRectsUpdateScrollRanges(); 228 | if (event->reason() != Qt::FocusReason::MouseFocusReason || impl->restore_cursor_position_on_focus_click) { 229 | impl->ensureCursorIsVisibleH(); 230 | impl->ensureCursorIsVisibleV(); 231 | } 232 | viewport()->update(); 233 | } 234 | 235 | void TagsEdit::focusOutEvent(QFocusEvent* event) { 236 | QAbstractScrollArea::focusOutEvent(event); 237 | impl->setCursorVisible(false, this); 238 | impl->updateDisplayText(); 239 | impl->calcRectsUpdateScrollRanges(); 240 | viewport()->update(); 241 | } 242 | 243 | void TagsEdit::paintEvent(QPaintEvent* e) { 244 | QAbstractScrollArea::paintEvent(e); 245 | 246 | QPainter p(viewport()); 247 | 248 | p.setClipRect(impl->contentsRect()); 249 | 250 | auto const middle = impl->tags.cbegin() + static_cast(impl->editing_index); 251 | 252 | // tags 253 | impl->drawTags(p, std::ranges::subrange(impl->tags.cbegin(), middle)); 254 | 255 | if (impl->cursorVisible()) { 256 | impl->drawEditor(p, palette(), impl->offset()); 257 | } else if (!impl->editorText().isEmpty()) { 258 | impl->drawTags(p, std::ranges::subrange(middle, middle + 1)); 259 | } 260 | 261 | // tags 262 | impl->drawTags(p, std::ranges::subrange(middle + 1, impl->tags.cend())); 263 | } 264 | 265 | void TagsEdit::timerEvent(QTimerEvent* event) { 266 | if (event->timerId() == impl->blink_timer) { 267 | impl->blink_status = !impl->blink_status; 268 | viewport()->update(); 269 | } 270 | } 271 | 272 | void TagsEdit::mousePressEvent(QMouseEvent* event) { 273 | // we don't want to change cursor position if this event is part of focusIn 274 | using namespace std::chrono_literals; 275 | if (impl->restore_cursor_position_on_focus_click && elapsed(impl->focused_at) < 1ms) { 276 | return; 277 | } 278 | 279 | bool keep_cursor_visible = true; 280 | EVERLOAD_TAGS_SCOPE_EXIT { 281 | impl->update1(keep_cursor_visible); 282 | }; 283 | 284 | // remove or edit a tag 285 | for (size_t i = 0; i < impl->tags.size(); ++i) { 286 | if (!impl->tags[i].rect.translated(-impl->offset()).contains(event->pos())) { 287 | continue; 288 | } 289 | 290 | if (impl->inCrossArea(i, event->pos(), impl->offset())) { 291 | impl->removeTag(i); 292 | keep_cursor_visible = false; 293 | } else if (impl->editing_index == i) { 294 | impl->moveCursor( 295 | impl->text_layout.lineAt(0).xToCursor( 296 | (event->pos() - (impl->editorRect() - impl->pill_thickness).translated(-impl->offset()).topLeft()) 297 | .x()), 298 | false); 299 | } else { 300 | impl->editTag(i); 301 | } 302 | 303 | return; 304 | } 305 | 306 | // add new tag closest to the cursor 307 | for (auto it = begin(impl->tags); it != end(impl->tags); ++it) { 308 | // find the row 309 | if (it->rect.translated(-impl->offset()).bottom() < event->pos().y()) { 310 | continue; 311 | } 312 | 313 | // find the closest spot 314 | auto const row = it->rect.translated(-impl->offset()).top(); 315 | while (it != end(impl->tags) && it->rect.translated(-impl->offset()).top() == row && 316 | event->pos().x() > it->rect.translated(-impl->offset()).left()) { 317 | ++it; 318 | } 319 | 320 | impl->editNewTag(static_cast(std::distance(begin(impl->tags), it))); 321 | return; 322 | } 323 | 324 | // append a new nag 325 | impl->editNewTag(impl->tags.size()); 326 | } 327 | 328 | QSize TagsEdit::sizeHint() const { 329 | return minimumSizeHint(); 330 | } 331 | 332 | QSize TagsEdit::minimumSizeHint() const { 333 | ensurePolished(); 334 | QFontMetrics fm = fontMetrics(); 335 | QRect rect(0, 0, impl->pillWidth(fm.maxWidth(), true), impl->pillHeight(fm.height())); 336 | rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins(); 337 | return rect.size(); 338 | } 339 | 340 | int TagsEdit::heightForWidth(int w) const { 341 | auto const content_width = w; 342 | QRect contents_rect(0, 0, content_width, 100); 343 | contents_rect -= contentsMargins() + viewport()->contentsMargins() + viewportMargins(); 344 | auto tags = impl->tags; 345 | contents_rect = impl->calcRects(contents_rect); 346 | contents_rect += contentsMargins() + viewport()->contentsMargins() + viewportMargins(); 347 | return contents_rect.height(); 348 | } 349 | 350 | void TagsEdit::keyPressEvent(QKeyEvent* event) { 351 | if (impl->read_only) { 352 | return; 353 | } 354 | 355 | if (event == QKeySequence::SelectAll) { 356 | impl->selectAll(); 357 | } else if (event == QKeySequence::SelectPreviousChar) { 358 | impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), true); 359 | } else if (event == QKeySequence::SelectNextChar) { 360 | impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), true); 361 | } else { 362 | switch (event->key()) { 363 | case Qt::Key_Left: 364 | if (impl->cursor == 0) { 365 | impl->editPreviousTag(); 366 | } else { 367 | impl->moveCursor(impl->text_layout.previousCursorPosition(impl->cursor), false); 368 | } 369 | break; 370 | case Qt::Key_Right: 371 | if (impl->cursor == impl->editorText().size()) { 372 | impl->editNextTag(); 373 | } else { 374 | impl->moveCursor(impl->text_layout.nextCursorPosition(impl->cursor), false); 375 | } 376 | break; 377 | case Qt::Key_Home: 378 | if (impl->cursor == 0) { 379 | impl->editTag(0); 380 | } else { 381 | impl->moveCursor(0, false); 382 | } 383 | break; 384 | case Qt::Key_End: 385 | if (impl->cursor == impl->editorText().size()) { 386 | impl->editTag(impl->tags.size() - 1); 387 | } else { 388 | impl->moveCursor(impl->editorText().length(), false); 389 | } 390 | break; 391 | case Qt::Key_Backspace: 392 | if (!impl->editorText().isEmpty()) { 393 | impl->removeBackwardOne(); 394 | } else if (impl->editing_index > 0) { 395 | impl->editPreviousTag(); 396 | } 397 | break; 398 | case Qt::Key_Space: 399 | if (!impl->editorText().isEmpty()) { 400 | impl->editNewTag(impl->editing_index + 1); 401 | } 402 | break; 403 | default: 404 | if (isAcceptableInput(*event)) { 405 | if (impl->hasSelection()) { 406 | impl->removeSelection(); 407 | } 408 | impl->editorText().insert(impl->cursor, event->text()); 409 | impl->cursor = impl->cursor + event->text().length(); 410 | break; 411 | } else { 412 | event->setAccepted(false); 413 | return; 414 | } 415 | } 416 | } 417 | 418 | impl->update1(); 419 | 420 | impl->completer->setCompletionPrefix(impl->editorText()); 421 | impl->completer->complete(); 422 | 423 | emit tagsEdited(); 424 | } 425 | 426 | void TagsEdit::completion(std::vector const& completions) { 427 | impl->completer = std::make_unique([&] { 428 | QStringList ret; 429 | std::copy(completions.begin(), completions.end(), std::back_inserter(ret)); 430 | return ret; 431 | }()); 432 | impl->setupCompleter(); 433 | } 434 | 435 | void TagsEdit::tags(std::vector const& tags) { 436 | impl->setTags(tags); 437 | impl->update1(); 438 | } 439 | 440 | std::vector TagsEdit::tags() const { 441 | std::vector ret(impl->tags.size()); 442 | std::transform(impl->tags.begin(), impl->tags.end(), ret.begin(), [](Tag const& tag) { return tag.text; }); 443 | assert(!ret.empty()); // Invariant-1 444 | if (ret[impl->editing_index].isEmpty() || (impl->unique && impl->isCurrentTagADuplicate())) { 445 | ret.erase(ret.begin() + static_cast(impl->editing_index)); 446 | } 447 | return ret; 448 | } 449 | 450 | void TagsEdit::mouseMoveEvent(QMouseEvent* event) { 451 | for (size_t i = 0; i < impl->tags.size(); ++i) { 452 | if (impl->inCrossArea(i, event->pos(), impl->offset())) { 453 | viewport()->setCursor(Qt::ArrowCursor); 454 | return; 455 | } 456 | } 457 | if (impl->contentsRect().contains(event->pos())) { 458 | viewport()->setCursor(Qt::IBeamCursor); 459 | } else { 460 | QAbstractScrollArea::mouseMoveEvent(event); 461 | } 462 | } 463 | 464 | void TagsEdit::config(Config config) { 465 | if (impl->unique && impl->unique != config.behavior.unique) { 466 | impl->removeDuplicates(); 467 | } 468 | static_cast(*impl) = config.style; 469 | static_cast(*impl) = config.behavior; 470 | impl->update1(); 471 | } 472 | 473 | Config TagsEdit::config() const { 474 | return Config{ 475 | .style = static_cast(*impl), 476 | .behavior = static_cast(*impl), 477 | }; 478 | } 479 | 480 | } // namespace everload_tags 481 | --------------------------------------------------------------------------------