├── .all-contributorsrc ├── .bumpversion.cfg ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.sh ├── dev_requirements.txt ├── entitlements.plist ├── icon.icns ├── images ├── Full_Disk_Access.png ├── installer.png ├── system_events_access.png ├── textinator_desktop_access.png └── textinator_settings.png ├── requirements.txt ├── setup.py ├── src ├── README.md ├── appkitgui.py ├── confirmation_window.py ├── icon.png ├── icon_paused.png ├── loginitems.py ├── macvision.py ├── pasteboard.py ├── textinator.py └── utils.py └── tests ├── __init__.py ├── conftest.py ├── data ├── Textinator.plist ├── hello.png ├── hello_world.png ├── hello_world_linebreaks.png ├── qrcode.png ├── qrcode_with_text.png └── world.png ├── loginitems.py ├── pasteboard.py └── test_textinator.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 75, 6 | "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat)](#contributors)", 7 | "commit": false, 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "bwagner", 12 | "name": "Bernhard Wagner", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/447049?v=4", 14 | "profile": "https://github.com/bwagner", 15 | "contributions": [ 16 | "ideas", 17 | "code", 18 | "test" 19 | ] 20 | } 21 | ], 22 | "contributorsPerLine": 7, 23 | "skipCi": true, 24 | "repoType": "github", 25 | "repoHost": "https://github.com", 26 | "projectName": "textinator", 27 | "projectOwner": "RhetTbull", 28 | "commitType": "docs" 29 | } 30 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.10.1 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 4 | serialize = {major}.{minor}.{patch} 5 | 6 | [bumpversion:file:src/textinator.py] 7 | parse = __version__\s=\s\"(?P\d+)\.(?P\d+)\.(?P\d+)\" 8 | serialize = {major}.{minor}.{patch} 9 | 10 | [bumpversion:file:setup.py] 11 | parse = __version__\s=\s\"(?P\d+)\.(?P\d+)\.(?P\d+)\" 12 | serialize = {major}.{minor}.{patch} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .metrics 2 | .DS_store 3 | __pycache__ 4 | .coverage 5 | .condaauto 6 | t.out 7 | .vscode/ 8 | .tox/ 9 | .idea/ 10 | dist/ 11 | build/ 12 | working/ 13 | .mypy_cache/ 14 | cli.spec 15 | *.pyc 16 | docsrc/_build/ 17 | venv/ 18 | .python-version 19 | cov.xml 20 | .eggs/ 21 | pyrightconfig.json 22 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | multi_line_output=3 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/psf/black 12 | rev: 22.10.0 13 | hooks: 14 | - id: black 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.12.0 17 | hooks: 18 | - id: isort 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [v0.10.1](https://github.com/RhetTbull/textinator/compare/v0.10.0...v0.10.1) 8 | 9 | > 4 May 2024 10 | 11 | - Version bump [`0d8fd5b`](https://github.com/RhetTbull/textinator/commit/0d8fd5b66cf33f6f9f538922e5d16f74585fe93c) 12 | - Fixed window level [`e3f1708`](https://github.com/RhetTbull/textinator/commit/e3f170835966b386c504e781e2512578acf20eac) 13 | 14 | #### [v0.10.0](https://github.com/RhetTbull/textinator/compare/v0.9.2...v0.10.0) 15 | 16 | > 4 May 2024 17 | 18 | - Feat may 2024 [`#33`](https://github.com/RhetTbull/textinator/pull/33) 19 | - FIXES: custom locations for screenshots (Issue #29) [`#30`](https://github.com/RhetTbull/textinator/pull/30) 20 | - Added confirmation window, #18 [`2c0b84b`](https://github.com/RhetTbull/textinator/commit/2c0b84beb24a21f8f1d947f04637fd390a26eab3) 21 | - Implemented #29 [`b38c918`](https://github.com/RhetTbull/textinator/commit/b38c918f56f12791718c8637c3ea2a35ce68e41a) 22 | - Add Show Last Detected Text menu, #32 [`d335b5c`](https://github.com/RhetTbull/textinator/commit/d335b5c0be896177384a00b8d0716278074a2097) 23 | - Added test for show last text detection, #32 [`21214e7`](https://github.com/RhetTbull/textinator/commit/21214e7aece4406f867882d7e0acd875eb40c8c2) 24 | - Version bump [`52f4c4c`](https://github.com/RhetTbull/textinator/commit/52f4c4c311f0f9445f0e330d891cfda19bc3a0b5) 25 | 26 | #### [v0.9.2](https://github.com/RhetTbull/textinator/compare/v0.9.1...v0.9.2) 27 | 28 | > 2 December 2023 29 | 30 | - ADDS: isort, black pre-commit [`#28`](https://github.com/RhetTbull/textinator/pull/28) 31 | - FIXES: issue #26: menu entry capitalization [`#27`](https://github.com/RhetTbull/textinator/pull/27) 32 | - docs: add bwagner as a contributor for test [`#25`](https://github.com/RhetTbull/textinator/pull/25) 33 | - FIXES: tests on 14.1.1 (Sonoma) Apple M1 Max [`#24`](https://github.com/RhetTbull/textinator/pull/24) 34 | - Fixes #19 [`#23`](https://github.com/RhetTbull/textinator/pull/23) 35 | - Merge pull request #23 from RhetTbull/auto_switch_icon_19 [`#19`](https://github.com/RhetTbull/textinator/issues/19) 36 | - Fixes #19 [`#19`](https://github.com/RhetTbull/textinator/issues/19) 37 | - Added test for #16 [`55471ab`](https://github.com/RhetTbull/textinator/commit/55471ab2d764bc3e80193257fc5c0f4327b6f93f) 38 | - Updated build script [`0df7b0e`](https://github.com/RhetTbull/textinator/commit/0df7b0e1ce2166f900d5f70bbe215e0097662aa2) 39 | - Updated dependencies for python 3.11 [`8ea99e8`](https://github.com/RhetTbull/textinator/commit/8ea99e81fd0e14938563b8ce2c7fd72f47ca54ab) 40 | - Updated README developer notes, #22 [`22d6bda`](https://github.com/RhetTbull/textinator/commit/22d6bda87f5a9f37a0ef2d54b8c6bd64de208542) 41 | - Updated dependencies, #21 [`502cb6b`](https://github.com/RhetTbull/textinator/commit/502cb6b6f372f3e1a83358c3c84b4801da085a68) 42 | 43 | #### [v0.9.1](https://github.com/RhetTbull/textinator/compare/v0.9.0...v0.9.1) 44 | 45 | > 25 October 2022 46 | 47 | - Add tests [`#15`](https://github.com/RhetTbull/textinator/pull/15) 48 | - Added Services menu action for detecting text in image files #5 [`#13`](https://github.com/RhetTbull/textinator/pull/13) 49 | - Added initial tests [`86a562c`](https://github.com/RhetTbull/textinator/commit/86a562c46bf2bead5cb621999c3cdfa536c36483) 50 | - Added tests [`327908d`](https://github.com/RhetTbull/textinator/commit/327908deaa9696404280b652a432c5b60a51ee10) 51 | - Improved comments, some refactoring [`c7a18be`](https://github.com/RhetTbull/textinator/commit/c7a18bed9c6754ee69c0092b8064b0a19945ef49) 52 | - Updated comments [`b11e7b1`](https://github.com/RhetTbull/textinator/commit/b11e7b1c5744a4d6b8ff58f6afd9fa63547cce18) 53 | - Updated docs with developer notes [`299cfbf`](https://github.com/RhetTbull/textinator/commit/299cfbfd8a7a440e637c8ccda2458503644f961b) 54 | 55 | #### [v0.9.0](https://github.com/RhetTbull/textinator/compare/v0.8.2...v0.9.0) 56 | 57 | > 21 October 2022 58 | 59 | - Feature screenshot in clipboard 004 [`#12`](https://github.com/RhetTbull/textinator/pull/12) 60 | - Refactored textinator to separate src folder [`29492bd`](https://github.com/RhetTbull/textinator/commit/29492bd6214aa3d1fb96b809cb486e8bf46d9e00) 61 | - Implemented #4, clipboard detection [`82ffe10`](https://github.com/RhetTbull/textinator/commit/82ffe10bc074ea7cdf8f105fda697b506b893558) 62 | - Updated dependencies, #11 [`972565d`](https://github.com/RhetTbull/textinator/commit/972565daf6278f05b56f0f3cbab141cbc1b22295) 63 | - Refactored source code [`e235d72`](https://github.com/RhetTbull/textinator/commit/e235d72946f30a76abc4f1c5595608b916a797e0) 64 | - Version bump [`f1dbcd5`](https://github.com/RhetTbull/textinator/commit/f1dbcd5772c17fa6c3bbbcd0fba67441864d05fb) 65 | 66 | #### [v0.8.2](https://github.com/RhetTbull/textinator/compare/v0.8.1...v0.8.2) 67 | 68 | > 18 October 2022 69 | 70 | - Bug fix for pause/resume [`eebae51`](https://github.com/RhetTbull/textinator/commit/eebae51fd70f1faf41efe3d080f6652735fcef2e) 71 | - Bumped version [`110ffee`](https://github.com/RhetTbull/textinator/commit/110ffee612f96aa7d72c6ba879feadab88de98b5) 72 | 73 | #### [v0.8.1](https://github.com/RhetTbull/textinator/compare/v0.8.0...v0.8.1) 74 | 75 | > 18 October 2022 76 | 77 | - docs: add bwagner as a contributor for code [`#9`](https://github.com/RhetTbull/textinator/pull/9) 78 | - FIXES: typo in function call .initWithCompletionHandler_ [`#8`](https://github.com/RhetTbull/textinator/pull/8) 79 | - docs: add bwagner as a contributor for ideas [`#7`](https://github.com/RhetTbull/textinator/pull/7) 80 | - docs: create .all-contributorsrc [skip ci] [`725b6c9`](https://github.com/RhetTbull/textinator/commit/725b6c92017297b0c22366a85afd09bb1de1f5ca) 81 | - docs: update README.md [skip ci] [`ec70e57`](https://github.com/RhetTbull/textinator/commit/ec70e57314b1df4a4a4d9ae7443719e9874fb5cf) 82 | - Added pause/resume, #3 [`4c33e6a`](https://github.com/RhetTbull/textinator/commit/4c33e6ac655e74d5050a24c6c89a8209d9aacd8c) 83 | - Bumped version [`44bd293`](https://github.com/RhetTbull/textinator/commit/44bd293ff2e5786b239856a99e042a46280f980e) 84 | - Updated all-contributors badge [`6337c32`](https://github.com/RhetTbull/textinator/commit/6337c3236e30affd78708611b6c9333b418db959) 85 | 86 | #### [v0.8.0](https://github.com/RhetTbull/textinator/compare/v0.7.2...v0.8.0) 87 | 88 | > 28 September 2022 89 | 90 | - Added 'Start Textinator on login' option [`209b317`](https://github.com/RhetTbull/textinator/commit/209b3172683aede28dab76b5b7009df33cff417f) 91 | - Fixed unnecessary import [`614f362`](https://github.com/RhetTbull/textinator/commit/614f362b570aaa5270348e16d2edd93868cd2b4f) 92 | - Updated dependencies [`cf18c41`](https://github.com/RhetTbull/textinator/commit/cf18c41bce33136180924b9761a8a65c1f09c446) 93 | 94 | #### [v0.7.2](https://github.com/RhetTbull/textinator/compare/v0.7.0...v0.7.2) 95 | 96 | > 18 September 2022 97 | 98 | - Fixed logging problem [`ae1db74`](https://github.com/RhetTbull/textinator/commit/ae1db7412701218288525346139e6dacdb3526c4) 99 | 100 | #### [v0.7.0](https://github.com/RhetTbull/textinator/compare/0.4.0...v0.7.0) 101 | 102 | > 18 September 2022 103 | 104 | - Request Desktop access if needed [`fe70412`](https://github.com/RhetTbull/textinator/commit/fe70412f073195cbffd163a2733af3dff9e4a14c) 105 | - Added QR code scanning [`1096439`](https://github.com/RhetTbull/textinator/commit/10964399032a1237917c97bc2e97a38c1947f9e5) 106 | - Updated logging [`fc6a53b`](https://github.com/RhetTbull/textinator/commit/fc6a53b6bbee197f85a0aa2942bbf8646439b987) 107 | 108 | #### 0.4.0 109 | 110 | > 21 June 2022 111 | 112 | - First commit [`b8f5671`](https://github.com/RhetTbull/textinator/commit/b8f567110016a4e51764f4f3a8d34aecb80c732c) 113 | - Feature complete [`b738d4c`](https://github.com/RhetTbull/textinator/commit/b738d4c65fa648f72f7474ba62ef9187f097af32) 114 | - Added language selection, #2 [`01365e4`](https://github.com/RhetTbull/textinator/commit/01365e4224a2beaf28a262e2ba868184ba481b72) 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 Rhet Turnbull 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textinator 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat)](#contributors) 4 | 5 | 6 | Simple macOS StatusBar / menu bar app to perform automatic text detection on screenshots. 7 | 8 | ## Overview 9 | 10 | Install the app per [instructions](#installation) below. Then, take a screenshot of a region of the screen using ⌘ + ⇧ + 4 (`Cmd + Shift + 4`). The app will automatically detect any text in the screenshot and copy it to your clipboard. 11 | 12 | [![Watch the screencast](https://img.youtube.com/vi/K_3MXOeBBdY/maxresdefault.jpg)](https://youtu.be/K_3MXOeBBdY) 13 | 14 | ## Installation 15 | 16 | Download and open the latest installer DMG from the [release](https://github.com/RhetTbull/textinator/releases) page then drag the Textinator icon to Applications and follow instructions below to grant Desktop access and optionally grant Full Disk Access. 17 | 18 | To launch Textinator the first time you'll need to right-click on the app icon and select "Open" otherwise you may get a warning about unknown developer as the app is not signed with an Apple Developer ID. 19 | 20 | ![Installer DMG](images/installer.png) 21 | 22 | Alternatively, to build from source: 23 | 24 | - clone the repo 25 | - cd into the repo directory 26 | - create a virtual environment and activate it 27 | - python3 -m pip install -r requirements.txt 28 | - python3 setup.py py2app 29 | - Copy dist/textinator.app to /Applications 30 | - Follow instructions below to grant Desktop and optionally Full Disk Access 31 | 32 | See also [Developer Notes](#developer-notes) below. 33 | 34 | Grant Desktop access: 35 | 36 | Textinator works by monitoring the file system for new screenshots. The macOS security model prevents apps from accessing files and folders without the user's explicit permission. The first time you launch Textinator, you will be prompted to grant it access to your Desktop. 37 | 38 | ![Desktop access](images/textinator_desktop_access.png) 39 | 40 | The default location for new screenshots on your Mac is the Desktop folder so Desktop access should be sufficient in most cases. If you want Textinator to detect screenshots in other locations or if you have [changed the default location for new screenshots](https://support.apple.com/en-us/HT201361), you will need to grant Full Disk Access. 41 | 42 | Grant Full Disk Access: 43 | 44 | - Open System Settings...>Privacy & Security> Full Disk Access 45 | - Click the padlock if locked to unlock it and add Textinator to the list of allowed apps 46 | 47 | ![System Preferences > Security & Privacy](images/Full_Disk_Access.png) 48 | 49 | ## Upgrading 50 | 51 | To upgrade to the latest version, download the latest installer DMG from [releases](https://github.com/RhetTbull/textinator/releases) and drag the Textinator icon to Applications. If you have previously granted Textinator Full Disk Access, you will need to remove Textinator from Full Disk Access and re-add it per the instructions above. (This is a limitation of the macOS security model and not something Textinator can control.) 52 | 53 | ## Usage 54 | 55 | - Launch Textinator from the Applications folder 56 | - Grant Desktop access if prompted 57 | - Click the menu bar icon to see preferences 58 | 59 | ![Menu Bar Icon](images/textinator_settings.png) 60 | 61 | - Press ⌘ + ⇧ + 4 (`Cmd + Shift + 4`) to take a screenshot then paste the detected text wherever you'd like it to be. 62 | 63 | - Textinator can also monitor the clipboard for changes which means you can also copy an image from any app or press Control + ⌘ + ⇧ + 4 (`Ctrl + Cmd + Shift + 4`) to take a screenshot and copy it to the clipboard without creating a screenshot file. Textinator will then detect any text in the image and copy it to the clipboard, overwriting the copied image. This feature can be disabled by unchecking the "Detect text in images on clipboard" checkbox in the menu. 64 | 65 | - You can also use Textinator from the [Services menu](https://macreports.com/what-is-the-services-menu-in-macos/) in Finder (and other apps). To use this feature, right click on an image file in Finder and select `Services > Detect Text With Textinator` from the context menu. Alternatively, you can select `Finder > Services > Detect text with Textinator` from the menu bar. 66 | 67 | ## Settings 68 | 69 | - `Text detection threshold confidence`: The confidence threshold for text detection. The higher the value, the more accurate the text detection will be but a higher setting may result in some text not being detected (because the detected text was below the specified threshold). The default value is 'Low' which is equivalent to a [VNRecognizeTextRequest](https://developer.apple.com/documentation/vision/vnrecognizetextrequest?language=objc) confidence threshold of `0.3` (Medium = `0.5`, High = `0.8`). 70 | - `Text recognition language`: Select language for text recognition (languages listed by [ISO code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) and are limited to those which your version of macOS supports). 71 | - `Always detect English`: If checked, always attempts to detect English text in addition to the primary language selected by `Text recognition language` setting. 72 | - `Detect text in images on clipboard`: If checked, Textinator will monitor the clipboard for changes and detect any text in any images copied to the clipboard. This feature can be disabled by unchecking the "Detect text in images on clipboard" checkbox in the menu. 73 | - `Pause text detection`: If checked, Textinator will not detect text in screenshots or images copied to the clipboard. If paused, the menu bar icon will change and the menu will show `Resume text detection` instead of `Pause text detection`. 74 | - `Detect QR Codes`: In addition to detecting text, also detect QR codes and copy the decoded payload text to the clipboard. 75 | - `Notification`: Whether or not to show a notification when text is detected. 76 | - `Keep linebreaks`: Whether or not to keep linebreaks in the detected text; if not set, linebreaks will be stripped. 77 | - `Append to clipboard`: Append to the clipboard instead of overwriting it. 78 | - `Clear clipboard`: Clear the clipboard. 79 | - `Confirm clipboard changes`: Show a confirmation dialog with detected text before copying to the clipboard. 80 | - `Start Textinator on login`: Add Textinator to the Login Items list so it will launch automatically when you login. This will cause Textinator to prompt for permission to send AppleScript events to the System Events app (see screnshot below). 81 | - `About Textinator`: Show the about dialog. 82 | - `Quit Textinator`: Quit Textinator. 83 | 84 | When you first select `Start Textinator on login`, you will be prompted to allow Textinator to send AppleScript events to the System Events app. This is required to add Textinator to the Login Items list. The screenshot below shows the prompt you will see. 85 | 86 | ![System Events permission](images/system_events_access.png) 87 | 88 | ## Inspiration 89 | 90 | I heard [mikeckennedy](https://github.com/mikeckennedy) mention [Text Sniper](https://textsniper.app/) on [Python Bytes](https://pythonbytes.fm/) podcast [#284](https://pythonbytes.fm/episodes/show/284/spicy-git-for-engineers) and thought "That's neat! I bet I could make a clone in Python!" and here it is. You should listen to Python Bytes if you don't already and you should go buy Text Sniper! 91 | 92 | This project took a few hours and the whole thing is a few hundred lines of Python. It was fun to show that you can build a really useful macOS native app in just a little bit of Python. 93 | 94 | Textinator was featured on [Talk Python to Me](https://www.youtube.com/watch?v=ndFFgJhrUhQ&t=810s)! Thanks [Michael Kennedy](https://twitter.com/mkennedy) for hosting me! 95 | 96 | ## How Textinator Works 97 | 98 | Textinator is built with [rumps (Ridiculously Uncomplicated macOS Python Statusbar apps)](https://github.com/jaredks/rumps) which is a python package for creating simple macOS Statusbar apps. 99 | 100 | At startup, Textinator starts a persistent [NSMetadataQuery Spotlight query](https://developer.apple.com/documentation/foundation/nsmetadataquery?language=objc) (using the [pyobjc](https://pyobjc.readthedocs.io/en/latest/) Python-to-Objective-C bridge) to detect when a new screenshot is created. 101 | 102 | When the user creates screenshot, the `NSMetadataQuery` query is fired and Textinator performs text detection using a [Vision](https://developer.apple.com/documentation/vision?language=objc) [VNRecognizeTextRequest](https://developer.apple.com/documentation/vision/vnrecognizetextrequest?language=objc) call. 103 | 104 | Textinator can also monitor the clipboard and detect text in images copied to the clipboard. 105 | 106 | ## Notes 107 | 108 | - If building with [pyenv](https://github.com/pyenv/pyenv) installed python, you'll need to build the python with framework support: 109 | - `env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install -v 3.9.11` 110 | - Requires a minimum of macOS Catalina (10.15). Tested on macOS Catalina (10.15.7), Big Sur (11.6.4), Ventura (13.5.1); should work on Catalina or newer. 111 | 112 | ## License 113 | 114 | MIT License 115 | 116 | ## See Also 117 | 118 | [Text Sniper](https://textsniper.app/) which inspired this project. 119 | 120 | ## Contributors ✨ 121 | 122 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
Bernhard Wagner
Bernhard Wagner

🤔 💻 ⚠️
134 | 135 | 136 | 137 | 138 | 139 | 140 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 141 | 142 | ## Developer Notes 143 | 144 | If you want to work on Textinator yourself or contribute changes, here are some notes: 145 | 146 | Clone the repo and cd into the repo directory. 147 | 148 | `git clone git@github.com:RhetTbull/textinator.git` 149 | `cd textinator` 150 | 151 | If you want to contribute back to Textinator, fork the repo and clone your fork instead. 152 | 153 | Install requirements and development requirements via pip: 154 | 155 | ```console 156 | python3 -m pip install -r requirements.txt -r dev_requirements.txt 157 | pre-commit install 158 | ``` 159 | 160 | See also notes below about [Testing](#testing). 161 | 162 | Building the DMG for distribution requires [create-dmg](https://github.com/create-dmg/create-dmg) which can be installed with [homebrew](https://brew.sh/): 163 | 164 | `brew install create-dmg` 165 | 166 | To build Textinator, run the `build.sh` script: 167 | 168 | `./build.sh` 169 | 170 | This script cleans out old build files, builds the app with [py2app](https://py2app.readthedocs.io/en/latest/), signs the app, and builds the DMG. 171 | 172 | Textinator stores it's preferences in `~/Library/Application\ Support/Textinator/Textinator.plist`. This is non-standard (by convention, apps store their preferences in `~/Library/Preferences/`), but RUMPS doesn't provide a method to access the Preferences folder and it does provide a method to access the Application Support folder (`rumps.App.open()`), so I went with that. 173 | 174 | The preferences can be read from the command line with: 175 | 176 | `defaults read ~/Library/Application\ Support/Textinator/Textinator.plist` 177 | 178 | For development and debugging it may be helpful to enable the debug log by setting `debug=1` in `Textinator.plist`. You can do this from the command line with: 179 | 180 | `defaults write ~/Library/Application\ Support/Textinator/Textinator.plist debug -bool true` 181 | 182 | Similarly, you can disable the debug log with: 183 | 184 | `defaults write ~/Library/Application\ Support/Textinator/Textinator.plist debug -bool false` 185 | 186 | When `debug` is enabled, Textinator will log to `~/Library/Application\ Support/Textinator/Textinator.log`. I find this more convenient than using the macOS Console app. Textinator will always log to the Console log as well so you can use Console if you prefer and filter on `Textinator`. 187 | 188 | Most features of the app can be tested by simply running the `textinator.py` script: `python3 src/textinator.py`. The `Services menu` feature requires the app be built and installed because it needs runtime access to information in the app bundle's `Info.plist` which is built by `py2app`. 189 | 190 | The version number is incremented by [bump2version](https://github.com/c4urself/bump2version) which is installed via `python3 -m pip install -r dev_requirements.txt`. To increment the version number, run `bumpversion patch` or `bumpversion minor` or `bumpversion major` as appropriate. See `bumpversion --help` for more information. 191 | 192 | I've tried to document the code well so that you can use Textinator as a template for your own apps. Some of the features (such as creating a Services menu item) are not well documented (especially with respect to doing these things in python) and took me a lot of trial and error to figure out. I hope that this project will help others who want to build macOS native apps in python. 193 | 194 | ## Testing 195 | 196 | Textinator uses [pytest](https://docs.pytest.org/en/7.1.x/) to run unit tests. To run the tests, run `pytest` from the project root directory. Before running the tests, you'll need to install the development requirements via `python3 -m pip install -r dev_requirements.txt`. You will also need to enable your Terminal app to control your computer in `System Preferences > Security & Privacy > Privacy > Accessibility`. This is because the testing uses System Events scripting via applescript to simulate user actions such as clicking menu items. Your Terminal will also need to be granted Full Disk Access in `System Preferences > Security & Privacy > Privacy > Full Disk Access`. 197 | 198 | The test suite requires the built app to be installed in `/Applications/Textinator.app`. Before running tests, uses `./build.sh` to build the app then copy `dist/Textinator.app` to `/Applications/Textinator.app`. 199 | 200 | The tests will modify the Textinator preferences but will backup your original preferences and restore them when testing is completed. The tests will also modify the clipboard and will create temporary files on the Desktop which will be cleaned up when testing is completed. 201 | 202 | The test suite is slow due to required sleeps to allow the app to respond, Spotlight to index new files, etc. (Takes approximately 5 minutes to run on my MacBook Air). Because the test suite interacts with the user interface, it is best not to touch the keyboard or mouse while the tests are running. 203 | 204 | The Services menu item is not tested by the test suite so this feature should be tested manually. 205 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Build, sign and package Textinator as a DMG file for release 4 | # this requires create-dmg: `brew install create-dmg` to install 5 | 6 | # build with py2app 7 | echo "Cleaning up old build files..." 8 | test -d dist && rm -rf dist/ 9 | test -d build && rm -rf build/ 10 | 11 | echo "Running py2app" 12 | python3 setup.py py2app 13 | 14 | # TODO: this doesn't appear to be needed (only for sandboxed apps) 15 | # py2app will sign the app with the ad-hoc certificate 16 | # sign with ad-hoc certificate (if you have an Apple Developer ID, you can use your developer certificate instead) 17 | # for the app to send AppleEvents to other apps, it needs to be signed and include the 18 | # com.apple.security.automation.apple-events entitlement in the entitlements file 19 | # --force: force signing even if the app is already signed 20 | # --deep: recursively sign all embedded frameworks and plugins 21 | # --options=runtime: Preserve the hardened runtime version 22 | # --entitlements: use specified the entitlements file 23 | # -s -: sign the code at the path(s) given using this identity; "-" means use the ad-hoc certificate 24 | # echo "Signing with codesign" 25 | # codesign \ 26 | # --force \ 27 | # --deep \ 28 | # --options=runtime \ 29 | # --preserve-metadata=identifier,entitlements,flags,runtime \ 30 | # --entitlements=entitlements.plist \ 31 | # -s - \ 32 | # dist/Textinator.app 33 | 34 | # create installer DMG 35 | # to add a background image to the DMG, add the following to the create-dmg command: 36 | # --background "installer_background.png" \ 37 | echo "Creating DMG" 38 | test -f Textinator-Installer.dmg && rm Textinator-Installer.dmg 39 | create-dmg \ 40 | --volname "Textinator Installer" \ 41 | --volicon "icon.icns" \ 42 | --window-pos 200 120 \ 43 | --window-size 800 400 \ 44 | --icon-size 100 \ 45 | --icon "Textinator.app" 200 190 \ 46 | --hide-extension "Textinator.app" \ 47 | --app-drop-link 600 185 \ 48 | "Textinator-Installer.dmg" \ 49 | "dist/" 50 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | bump2version>=1.0.1,<2.0.0 2 | osxmetadata>=1.0.0,<2.0.0 3 | pytest>=7.1.3,<8.0.0 4 | pre-commit 5 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | 8 | -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/icon.icns -------------------------------------------------------------------------------- /images/Full_Disk_Access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/images/Full_Disk_Access.png -------------------------------------------------------------------------------- /images/installer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/images/installer.png -------------------------------------------------------------------------------- /images/system_events_access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/images/system_events_access.png -------------------------------------------------------------------------------- /images/textinator_desktop_access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/images/textinator_desktop_access.png -------------------------------------------------------------------------------- /images/textinator_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/images/textinator_settings.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | py-applescript==1.0.3 2 | py2app>=0.28.6 3 | pyobjc-core>=9.2 4 | pyobjc-framework-cocoa>=9.2 5 | pyobjc-framework-coreml>=9.2 6 | pyobjc-framework-quartz>=9.2 7 | pyobjc-framework-vision>=9.2 8 | rumps>=0.4.0,<0.5.0 9 | wheel>=0.41.2 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a setup.py script generated by py2applet 3 | 4 | Usage: 5 | python setup.py py2app 6 | """ 7 | 8 | from setuptools import setup 9 | 10 | # The version number; do not change this manually! It is updated by bumpversion (https://github.com/c4urself/bump2version) 11 | __version__ = "0.10.1" 12 | 13 | # The file that contains the main application 14 | APP = ["src/textinator.py"] 15 | 16 | # Include additional python modules here; probably not the best way to do this 17 | # but I couldn't figure out how else to get py2app to include modules in the src/ folder 18 | DATA_FILES = [ 19 | "src/appkitgui.py", 20 | "src/confirmation_window.py", 21 | "src/icon.png", 22 | "src/icon_paused.png", 23 | "src/loginitems.py", 24 | "src/macvision.py", 25 | "src/pasteboard.py", 26 | "src/utils.py", 27 | ] 28 | 29 | # These values will be included by py2app into the Info.plist file in the App bundle 30 | # See https://developer.apple.com/documentation/bundleresources/information_property_list?language=objc 31 | # for more information 32 | PLIST = { 33 | # LSUIElement tells the OS that this app is a background app that doesn't appear in the Dock 34 | "LSUIElement": True, 35 | # CFBundleShortVersionString is the version number that appears in the App's About box 36 | "CFBundleShortVersionString": __version__, 37 | # CFBundleVersion is the build version (here we use the same value as the short version) 38 | "CFBundleVersion": __version__, 39 | # NSDesktopFolderUsageDescription is the message that appears when the app asks for permission to access the Desktop folder 40 | # Likewise for NSDocumentsFolderUsageDescription and NSDownloadsFolderUsageDescription 41 | "NSDesktopFolderUsageDescription": "Textinator needs access to your Desktop folder to detect new screenshots. " 42 | "If you have changed the default location for screenshots, " 43 | "you will also need to grant Textinator full disk access in " 44 | "System Preferences > Security & Privacy > Privacy > Full Disk Access.", 45 | "NSDocumentsFolderUsageDescription": "Textinator needs access to your Documents folder to detect new screenshots. ", 46 | "NSDownloadsFolderUsageDescription": "Textinator needs access to your Downloads folder to detect new screenshots. ", 47 | # NSAppleEventsUsageDescription is the message that appears when the app asks for permission to send Apple events 48 | "NSAppleEventsUsageDescription": "Textinator needs permission to send AppleScript events to add itself to Login Items.", 49 | # NSServices is a list of services that the app provides that will appear in the Services menu 50 | # For more information on NSServices, see: https://developer.apple.com/documentation/bundleresources/information_property_list/nsservices?language=objc 51 | "NSServices": [ 52 | { 53 | "NSMenuItem": {"default": "Detect Text With Textinator"}, 54 | "NSMessage": "detectTextInImage", 55 | "NSPortName": "Textinator", 56 | "NSUserData": "detectTextInImage", 57 | "NSRequiredContext": {"NSTextContent": "FilePath"}, 58 | "NSSendTypes": ["NSPasteboardTypeURL"], 59 | "NSSendFileTypes": ["public.image"], 60 | }, 61 | ], 62 | } 63 | 64 | # Options for py2app 65 | OPTIONS = { 66 | # The icon file to use for the app (this is App icon in Finder, not the status bar icon) 67 | "iconfile": "icon.icns", 68 | "plist": PLIST, 69 | } 70 | 71 | setup( 72 | app=APP, 73 | data_files=DATA_FILES, 74 | name="Textinator", 75 | options={"py2app": OPTIONS}, 76 | setup_requires=["py2app"], 77 | ) 78 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Source files for Textinator 2 | 3 | The source files are organized as individual python modules (files), not as a package. Any files added to `src` directory must also be added as in the `setup.py` `DATA_FILES` list to be included by py2app in the app bundle. 4 | 5 | `textinatory.py` is the main module and is the entry point for the app. It contains the `Textinator` class which is the main app class. 6 | -------------------------------------------------------------------------------- /src/appkitgui.py: -------------------------------------------------------------------------------- 1 | """Toolkit to help create a native macOS GUI with AppKit 2 | 3 | Copyright (c) 2023, Rhet Turnbull; licensed under MIT License. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import datetime 9 | import os 10 | import zoneinfo 11 | from collections.abc import Iterable 12 | from dataclasses import dataclass 13 | from typing import Any, Callable 14 | 15 | import AppKit 16 | from AppKit import ( 17 | NSApp, 18 | NSBox, 19 | NSButton, 20 | NSComboBox, 21 | NSDatePicker, 22 | NSImageView, 23 | NSScrollView, 24 | NSStackView, 25 | NSTextField, 26 | NSTextView, 27 | NSTimeZone, 28 | NSView, 29 | ) 30 | from Foundation import NSURL, NSDate, NSLog, NSMakeRect, NSMakeSize, NSObject 31 | from objc import objc_method, python_method, super 32 | 33 | ################################################################################ 34 | # Constants 35 | ################################################################################ 36 | 37 | # margin between window edge and content 38 | EDGE_INSET = 20 39 | 40 | # padding between elements 41 | PADDING = 8 42 | 43 | 44 | ################################################################################ 45 | # Window and Application 46 | ################################################################################ 47 | 48 | 49 | def window( 50 | title: str | None = None, 51 | size: tuple[int, int] = (600, 600), 52 | mask: int = AppKit.NSWindowStyleMaskTitled 53 | | AppKit.NSWindowStyleMaskClosable 54 | | AppKit.NSWindowStyleMaskResizable, 55 | ) -> AppKit.NSWindow: 56 | """Create a window with a title and size""" 57 | new_window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( 58 | NSMakeRect(0, 0, *size), 59 | mask, 60 | AppKit.NSBackingStoreBuffered, 61 | False, 62 | ) 63 | new_window.center() 64 | if title is not None: 65 | new_window.setTitle_(title) 66 | return new_window 67 | 68 | 69 | def main_view( 70 | window: AppKit.NSWindow, 71 | align: int = AppKit.NSLayoutAttributeLeft, 72 | padding: int = PADDING, 73 | edge_inset: tuple[float, float, float, float] | float = EDGE_INSET, 74 | ) -> AppKit.NSView: 75 | """Create a main NSStackView for the window which contains all other views 76 | 77 | Args: 78 | window: the NSWindow to attach the view to 79 | align: NSLayoutAttribute alignment constant 80 | padding: padding between elements 81 | edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets) 82 | """ 83 | 84 | # This uses appkitgui.StackView which is a subclass of NSStackView 85 | # that supports some list methods such as append, extend, remove, ... 86 | main_view = StackView.stackViewWithViews_(None) 87 | main_view.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationVertical) 88 | main_view.setSpacing_(padding) 89 | if isinstance(edge_inset, (int, float)): 90 | # use even insets 91 | edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset) 92 | else: 93 | edge_insets = edge_inset 94 | main_view.setEdgeInsets_(edge_insets) 95 | main_view.setDistribution_(AppKit.NSStackViewDistributionFill) 96 | main_view.setAlignment_(align) 97 | 98 | window.contentView().addSubview_(main_view) 99 | top_constraint = main_view.topAnchor().constraintEqualToAnchor_( 100 | main_view.superview().topAnchor() 101 | ) 102 | top_constraint.setActive_(True) 103 | bottom_constraint = main_view.bottomAnchor().constraintEqualToAnchor_( 104 | main_view.superview().bottomAnchor() 105 | ) 106 | bottom_constraint.setActive_(True) 107 | left_constraint = main_view.leftAnchor().constraintEqualToAnchor_( 108 | main_view.superview().leftAnchor() 109 | ) 110 | left_constraint.setActive_(True) 111 | right_constraint = main_view.rightAnchor().constraintEqualToAnchor_( 112 | main_view.superview().rightAnchor() 113 | ) 114 | right_constraint.setActive_(True) 115 | 116 | return main_view 117 | 118 | 119 | ################################################################################ 120 | # Custom views and control classes 121 | ################################################################################ 122 | 123 | 124 | class StackView(NSStackView): 125 | """NSStackView that supports list methods for adding child views""" 126 | 127 | @python_method 128 | def append(self, view: NSView): 129 | """Add view to stack""" 130 | self.addArrangedSubview_(view) 131 | 132 | @python_method 133 | def extend(self, views: Iterable[NSView]): 134 | """Extend stack with the contents of views""" 135 | for view in views: 136 | self.append(view) 137 | 138 | @python_method 139 | def insert(self, i: int, view: NSView): 140 | """Insert view at index i""" 141 | self.insertArrangedSubview_atIndex_(view, i) 142 | 143 | @python_method 144 | def remove(self, view: NSView): 145 | """Remove view from the stack""" 146 | self.removeArrangedSubview_(view) 147 | 148 | 149 | class ScrolledStackView(NSScrollView): 150 | """A scrollable stack view; use self.documentView() or self.stack to access the stack view""" 151 | 152 | def initWithStack_( 153 | self, 154 | stack: NSStackView | StackView, 155 | vscroll: bool = False, 156 | hscroll: bool = False, 157 | ): 158 | self = super().init() 159 | if not self: 160 | return 161 | 162 | self.stack: NSStackView | StackView = stack 163 | self.setHasVerticalScroller_(vscroll) 164 | self.setHasHorizontalScroller_(hscroll) 165 | self.setBorderType_(AppKit.NSNoBorder) 166 | self.setTranslatesAutoresizingMaskIntoConstraints_(False) 167 | self.setDrawsBackground_(False) 168 | self.setAutohidesScrollers_(True) 169 | 170 | self.setDocumentView_(self.stack) 171 | 172 | return self 173 | 174 | @python_method 175 | def append(self, view: NSView): 176 | """Add view to stack""" 177 | self.documentView().addArrangedSubview_(view) 178 | 179 | @python_method 180 | def extend(self, views: Iterable[NSView]): 181 | """Extend stack with the contents of views""" 182 | for view in views: 183 | self.documentView().append(view) 184 | 185 | @python_method 186 | def insert(self, i: int, view: NSView): 187 | """Insert view at index i""" 188 | self.documentView().insertArrangedSubview_atIndex_(view, i) 189 | 190 | @python_method 191 | def remove(self, view: NSView): 192 | """Remove view from the stack""" 193 | self.documentView().removeArrangedSubview_(view) 194 | 195 | def setSpacing_(self, spacing): 196 | self.stack.setSpacing_(spacing) 197 | 198 | def setOrientation_(self, orientation): 199 | self.stack.setOrientation_(orientation) 200 | 201 | def setDistribution_(self, distribution): 202 | self.stack.setDistribution_(distribution) 203 | 204 | def setAlignment_(self, alignment): 205 | self.stack.setAlignment_(alignment) 206 | 207 | def setEdgeInsets_(self, edge_inset): 208 | self.stack.setEdgeInsets_(edge_inset) 209 | 210 | 211 | class LinkLabel(NSTextField): 212 | """Uneditable text field that displays a clickable link""" 213 | 214 | def initWithText_URL_(self, text: str, url: str): 215 | self = super().init() 216 | 217 | if not self: 218 | return 219 | 220 | attr_str = self.attributedStringWithLinkToURL_text_(url, text) 221 | self.setAttributedStringValue_(attr_str) 222 | self.url = NSURL.URLWithString_(url) 223 | self.setBordered_(False) 224 | self.setSelectable_(False) 225 | self.setEditable_(False) 226 | self.setBezeled_(False) 227 | self.setDrawsBackground_(False) 228 | 229 | return self 230 | 231 | def resetCursorRects(self): 232 | self.addCursorRect_cursor_(self.bounds(), AppKit.NSCursor.pointingHandCursor()) 233 | 234 | def mouseDown_(self, event): 235 | AppKit.NSWorkspace.sharedWorkspace().openURL_(self.url) 236 | 237 | def mouseEntered_(self, event): 238 | AppKit.NSCursor.pointingHandCursor().push() 239 | 240 | def mouseExited_(self, event): 241 | AppKit.NSCursor.pop() 242 | 243 | def attributedStringWithLinkToURL_text_(self, url: str, text: str): 244 | linkAttributes = { 245 | AppKit.NSLinkAttributeName: NSURL.URLWithString_(url), 246 | AppKit.NSUnderlineStyleAttributeName: AppKit.NSUnderlineStyleSingle, 247 | AppKit.NSForegroundColorAttributeName: AppKit.NSColor.linkColor(), 248 | # AppKit.NSCursorAttributeName: AppKit.NSCursor.pointingHandCursor(), 249 | } 250 | return AppKit.NSAttributedString.alloc().initWithString_attributes_( 251 | text, linkAttributes 252 | ) 253 | 254 | 255 | class ComboBoxDelegate(NSObject): 256 | """Helper class to handle combo box events""" 257 | 258 | def initWithTarget_Action_(self, target: NSObject, action: Callable | str | None): 259 | self = super().init() 260 | if not self: 261 | return 262 | 263 | self.target = target 264 | self.action_change = action 265 | return self 266 | 267 | @objc_method 268 | def comboBoxSelectionDidChange_(self, notification): 269 | if self.action_change: 270 | if type(self.action_change) == str: 271 | self.target.performSelector_withObject_( 272 | self.action_change, notification.object() 273 | ) 274 | else: 275 | self.action_change(notification.object()) 276 | 277 | 278 | class ComboBox(NSComboBox): 279 | """NSComboBox that stores a reference to its delegate 280 | 281 | Note: 282 | This is required to maintain a reference to the delegate, otherwise it will 283 | not be retained after the ComboBox is created. 284 | """ 285 | 286 | def setDelegate_(self, delegate: NSObject | None): 287 | self.delegate = delegate 288 | if delegate is not None: 289 | super().setDelegate_(delegate) 290 | 291 | 292 | class ScrollViewWithTextView(NSScrollView): 293 | def initWithSize_VScroll_(self, size: tuple[float, float], vscroll: bool): 294 | self = super().initWithFrame_(NSMakeRect(0, 0, *size)) 295 | if not self: 296 | return 297 | self.setBorderType_(AppKit.NSBezelBorder) 298 | self.setHasVerticalScroller_(vscroll) 299 | self.setDrawsBackground_(True) 300 | self.setAutohidesScrollers_(True) 301 | self.setAutoresizingMask_( 302 | AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable 303 | ) 304 | self.setTranslatesAutoresizingMaskIntoConstraints_(False) 305 | 306 | width_constraint = self.widthAnchor().constraintEqualToConstant_(size[0]) 307 | width_constraint.setActive_(True) 308 | height_constraint = self.heightAnchor().constraintEqualToConstant_(size[1]) 309 | height_constraint.setActive_(True) 310 | 311 | contentSize = self.contentSize() 312 | self.textView = NSTextView.alloc().initWithFrame_(self.contentView().frame()) 313 | self.textView.setMinSize_(NSMakeSize(0.0, contentSize.height)) 314 | self.textView.setMaxSize_(NSMakeSize(float("inf"), float("inf"))) 315 | self.textView.setVerticallyResizable_(True) 316 | self.textView.setHorizontallyResizable_(False) 317 | self.setDocumentView_(self.textView) 318 | 319 | return self 320 | 321 | # provide access to some of the text view's methods 322 | def string(self): 323 | return self.textView.string() 324 | 325 | def setString_(self, text: str): 326 | self.textView.setString_(text) 327 | 328 | def setEditable_(self, editable: bool): 329 | self.textView.setEditable_(editable) 330 | 331 | def setSelectable_(self, selectable: bool): 332 | self.textView.setSelectable_(selectable) 333 | 334 | def setFont_(self, font: AppKit.NSFont): 335 | self.textView.setFont_(font) 336 | 337 | def setTextColor_(self, color: AppKit.NSColor): 338 | self.textView.setTextColor_(color) 339 | 340 | def setBackgroundColor_(self, color: AppKit.NSColor): 341 | self.textView.setBackgroundColor_(color) 342 | 343 | 344 | ################################################################################ 345 | # Helper functions to create views and controls 346 | ################################################################################ 347 | 348 | 349 | def hstack( 350 | align: int = AppKit.NSLayoutAttributeTop, 351 | distribute: int | None = AppKit.NSStackViewDistributionFill, 352 | vscroll: bool = False, 353 | hscroll: bool = False, 354 | views: ( 355 | Iterable[AppKit.NSView] | AppKit.NSArray | AppKit.NSMutableArray | None 356 | ) = None, 357 | edge_inset: tuple[float, float, float, float] | float = 0, 358 | ) -> StackView: 359 | """Create a horizontal StackView 360 | 361 | Args: 362 | align:NSLayoutAttribute alignment constant 363 | distribute: NSStackViewDistribution distrubution constant 364 | vscroll: True to add vertical scrollbar 365 | hscroll: True to add horizontal scrollbar 366 | views: iterable of NSViews to add to the stack 367 | edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets) 368 | 369 | Returns: StackView 370 | """ 371 | hstack = StackView.stackViewWithViews_(views) 372 | hstack.setSpacing_(PADDING) 373 | hstack.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationHorizontal) 374 | if distribute is not None: 375 | hstack.setDistribution_(distribute) 376 | hstack.setAlignment_(align) 377 | hstack.setTranslatesAutoresizingMaskIntoConstraints_(False) 378 | hstack.setHuggingPriority_forOrientation_( 379 | AppKit.NSLayoutPriorityDefaultHigh, 380 | AppKit.NSLayoutConstraintOrientationHorizontal, 381 | ) 382 | if edge_inset: 383 | if isinstance(edge_inset, (int, float)): 384 | # use even insets 385 | edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset) 386 | else: 387 | edge_insets = edge_inset 388 | hstack.setEdgeInsets_(edge_insets) 389 | if vscroll or hscroll: 390 | scroll_view = ScrolledStackView.alloc().initWithStack_(hstack, vscroll, hscroll) 391 | return scroll_view 392 | return hstack 393 | 394 | 395 | def vstack( 396 | align: int = AppKit.NSLayoutAttributeLeft, 397 | distribute: int | None = None, 398 | vscroll: bool = False, 399 | hscroll: bool = False, 400 | views: AppKit.NSArray | AppKit.NSMutableArray | None = None, 401 | edge_inset: tuple[float, float, float, float] | float = 0, 402 | ) -> StackView | ScrolledStackView: 403 | """Create a vertical StackView 404 | 405 | Args: 406 | align:NSLayoutAttribute alignment constant 407 | distribute: NSStackViewDistribution distrubution constant 408 | vscroll: True to add vertical scrollbar 409 | hscroll: True to add horizontal scrollbar 410 | views: iterable of NSViews to add to the stack 411 | edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets) 412 | 413 | Returns: StackView 414 | """ 415 | vstack = StackView.stackViewWithViews_(views) 416 | vstack.setSpacing_(PADDING) 417 | vstack.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationVertical) 418 | if distribute is not None: 419 | vstack.setDistribution_(distribute) 420 | vstack.setAlignment_(align) 421 | vstack.setTranslatesAutoresizingMaskIntoConstraints_(False) 422 | # TODO: set priority as arg? or let user set it later? 423 | vstack.setHuggingPriority_forOrientation_( 424 | AppKit.NSLayoutPriorityDefaultHigh, 425 | AppKit.NSLayoutConstraintOrientationVertical, 426 | ) 427 | if edge_inset: 428 | if isinstance(edge_inset, (int, float)): 429 | # use even insets 430 | edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset) 431 | else: 432 | edge_insets = edge_inset 433 | vstack.setEdgeInsets_(edge_insets) 434 | 435 | if vscroll or hscroll: 436 | scroll_view = ScrolledStackView.alloc().initWithStack_(vstack, vscroll, hscroll) 437 | return scroll_view 438 | return vstack 439 | 440 | 441 | def hspacer() -> NSStackView: 442 | """Create a horizontal spacer""" 443 | return vstack() 444 | 445 | 446 | def label(value: str) -> NSTextField: 447 | """Create a label""" 448 | label = NSTextField.labelWithString_(value) 449 | label.setEditable_(False) 450 | label.setBordered_(False) 451 | label.setBackgroundColor_(AppKit.NSColor.clearColor()) 452 | return label 453 | 454 | 455 | def link(text: str, url: str) -> NSTextField: 456 | """Create a clickable link label""" 457 | return LinkLabel.alloc().initWithText_URL_(text, url) 458 | 459 | 460 | def button(title: str, target: NSObject, action: Callable | str | None) -> NSButton: 461 | """Create a button""" 462 | button = NSButton.buttonWithTitle_target_action_(title, target, action) 463 | button.setTranslatesAutoresizingMaskIntoConstraints_(False) 464 | 465 | # set hugging priority and compression resistance to prevent button from resizing 466 | set_hugging_priority(button) 467 | set_compression_resistance(button) 468 | 469 | return button 470 | 471 | 472 | def checkbox(title: str, target: NSObject, action: Callable | str | None) -> NSButton: 473 | """Create a checkbox button""" 474 | checkbox = NSButton.buttonWithTitle_target_action_(title, target, action) 475 | checkbox.setButtonType_(AppKit.NSButtonTypeSwitch) # Switch button type 476 | return checkbox 477 | 478 | 479 | def radio_button( 480 | title: str, target: NSObject, action: Callable | str | None 481 | ) -> NSButton: 482 | """Create a radio button""" 483 | radio_button = NSButton.buttonWithTitle_target_action_(title, target, action) 484 | radio_button.setButtonType_(AppKit.NSRadioButton) 485 | return radio_button 486 | 487 | 488 | def combo_box( 489 | values: list[str] | None, 490 | target: NSObject, 491 | editable: bool = False, 492 | action_return: Callable | str | None = None, 493 | action_change: Callable | str | None = None, 494 | delegate: NSObject | None = None, 495 | width: float | None = None, 496 | ) -> NSComboBox: 497 | """Create a combo box 498 | 499 | Args: 500 | values: list of values to populate the combo box with 501 | target: target to send action to 502 | editable: whether the combo box is editable 503 | action_return: action to send when return is pressed (only called if editable is True) 504 | action_change: action to send when the selection is changed 505 | delegate: delegate to handle events; if not provided a default delegate is automatically created 506 | width: width of the combo box; if None, the combo box will resize to the contents 507 | 508 | 509 | Note: 510 | In order to handle certain events such as return being pressed, a delegate is 511 | required. If a delegate is not provided, a default delegate is automatically 512 | created which will call the action_return callback when return is pressed. 513 | If a delegate is provided, it may implement the following methods: 514 | 515 | - comboBoxSelectionDidChange 516 | - comboBox_textView_doCommandBySelector 517 | """ 518 | 519 | combo_box = ComboBox.alloc().initWithFrame_(NSMakeRect(0, 0, 100, 25)) 520 | combo_box.setTarget_(target) 521 | delegate = delegate or ComboBoxDelegate.alloc().initWithTarget_Action_( 522 | target, action_change 523 | ) 524 | combo_box.setDelegate_(delegate) 525 | if values: 526 | combo_box.addItemsWithObjectValues_(values) 527 | combo_box.selectItemAtIndex_(0) 528 | if action_return: 529 | combo_box.setAction_(action_return) 530 | combo_box.setCompletes_(True) 531 | combo_box.setEditable_(editable) 532 | 533 | if width is not None: 534 | constrain_to_width(combo_box, width) 535 | return combo_box 536 | 537 | 538 | def hseparator() -> NSBox: 539 | """Create a horizontal separator""" 540 | separator = NSBox.alloc().init() 541 | separator.setBoxType_(AppKit.NSBoxSeparator) 542 | separator.setTranslatesAutoresizingMaskIntoConstraints_(False) 543 | return separator 544 | 545 | 546 | def image_view( 547 | path: str | os.PathLike, 548 | width: int | None = None, 549 | height: int | None = None, 550 | scale: int = AppKit.NSImageScaleProportionallyUpOrDown, 551 | align: int = AppKit.NSImageAlignCenter, 552 | ) -> NSImageView: 553 | """Create an image view from a an image file. 554 | 555 | Args: 556 | path: path to the image file 557 | width: width to constrain the image to; if None, the image will not be constrained 558 | height: height to constrain the image to; if None, the image will not be constrained 559 | scale: scaling mode for the image 560 | align: alignment mode for the image 561 | 562 | Returns: NSImageView 563 | 564 | Note: if only one of width or height set, the other will be scaled to maintain aspect ratio. 565 | If image is smaller than the specified width or height and scale is set to AppKit.NSImageScaleNone, 566 | the image frame will be larger than the image and the image will be aligned according to align. 567 | """ 568 | image = AppKit.NSImage.alloc().initByReferencingFile_(str(path)) 569 | image_view = NSImageView.imageViewWithImage_(image) 570 | image_view.setImageScaling_(scale) 571 | image_view.setImageAlignment_(align) 572 | image_view.setTranslatesAutoresizingMaskIntoConstraints_(False) 573 | 574 | # if width or height set, constrain to that size 575 | # if only one of width or height is set, constrain to that size and scale the other to maintain aspect ratio 576 | # if this is not done, the NSImageView intrinsic size may be larger than the window and thus disrupt the layout 577 | 578 | if width: 579 | image_view.widthAnchor().constraintEqualToConstant_(width).setActive_(True) 580 | if not height: 581 | aspect_ratio = image.size().width / image.size().height 582 | scaled_height = width / aspect_ratio 583 | image_view.heightAnchor().constraintEqualToConstant_( 584 | scaled_height 585 | ).setActive_(True) 586 | if height: 587 | image_view.heightAnchor().constraintEqualToConstant_(height).setActive_(True) 588 | if not width: 589 | aspect_ratio = image.size().width / image.size().height 590 | scaled_width = height * aspect_ratio 591 | image_view.widthAnchor().constraintEqualToConstant_( 592 | scaled_width 593 | ).setActive_(True) 594 | 595 | return image_view 596 | 597 | 598 | def date_picker( 599 | style: int = AppKit.NSDatePickerStyleClockAndCalendar, 600 | elements: int = AppKit.NSDatePickerElementFlagYearMonthDay, 601 | mode: int = AppKit.NSDatePickerModeSingle, 602 | date: datetime.date | datetime.datetime | None = None, 603 | target: NSObject | None = None, 604 | action: Callable | str | None = None, 605 | size: tuple[int, int] = (200, 50), 606 | ) -> NSDatePicker: 607 | """Create a date picker 608 | 609 | Args: 610 | style: style of the date picker, an AppKit.NSDatePickerStyle 611 | elements: elements to display in the date picker, an AppKit.NSDatePickerElementFlag 612 | mode: mode of the date picker, an AppKit.NSDatePickerMode 613 | date: initial date of the date picker; if None, defaults to the current date 614 | target: target to send action to 615 | action: action to send when the date is changed 616 | size: size of the date picker 617 | 618 | Returns: NSDatePicker 619 | """ 620 | date = date or datetime.date.today() 621 | date_picker = NSDatePicker.alloc().initWithFrame_(NSMakeRect(0, 0, *size)) 622 | date_picker.setDatePickerStyle_(style) 623 | date_picker.setDatePickerElements_(elements) 624 | date_picker.setDatePickerMode_(mode) 625 | date_picker.setDateValue_(date) 626 | date_picker.setTimeZone_(NSTimeZone.localTimeZone()) 627 | date_picker.setTranslatesAutoresizingMaskIntoConstraints_(False) 628 | 629 | if target: 630 | date_picker.setTarget_(target) 631 | if action: 632 | date_picker.setAction_(action) 633 | return date_picker 634 | 635 | 636 | def time_picker( 637 | style: int = AppKit.NSDatePickerStyleTextFieldAndStepper, 638 | elements: int = AppKit.NSDatePickerElementFlagHourMinute, 639 | mode: int = AppKit.NSDatePickerModeSingle, 640 | time: datetime.datetime | datetime.time | None = None, 641 | target: NSObject | None = None, 642 | action: Callable | str | None = None, 643 | ) -> NSDatePicker: 644 | """Create a time picker 645 | 646 | Args: 647 | style: style of the date picker, an AppKit.NSDatePickerStyle 648 | elements: elements to display in the date picker, an AppKit.NSDatePickerElementFlag 649 | mode: mode of the date picker, an AppKit.NSDatePickerMode 650 | time: initial time of the date picker; if None, defaults to the current time 651 | target: target to send action to 652 | action: action to send when the date is changed 653 | 654 | Returns: NSDatePicker 655 | 656 | 657 | Note: This function is a wrapper around date_picker, with the date picker style set to 658 | display a time picker. 659 | """ 660 | # if time is only a time, convert to datetime with today's date 661 | # as the date picker requires a datetime or date 662 | if isinstance(time, datetime.time): 663 | time = datetime.datetime.combine(datetime.date.today(), time) 664 | time = time or datetime.datetime.now() 665 | return date_picker( 666 | style=style, 667 | elements=elements, 668 | mode=mode, 669 | date=time, 670 | target=target, 671 | action=action, 672 | ) 673 | 674 | 675 | def text_view( 676 | size: tuple[float, float] = (400, 100), vscroll: bool = True 677 | ) -> NSTextView: 678 | """Create a text view with optional vertical scroll""" 679 | return ScrollViewWithTextView.alloc().initWithSize_VScroll_(size, vscroll) 680 | 681 | 682 | def text_field( 683 | size: tuple[float, float] = (200, 25), 684 | placeholder: str | None = None, 685 | target: NSObject | None = None, 686 | action: Callable | str | None = None, 687 | ) -> NSTextField: 688 | """Create a text field""" 689 | text_field = NSTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *size)) 690 | text_field.setBezeled_(True) 691 | text_field.setBezelStyle_(AppKit.NSTextFieldSquareBezel) 692 | text_field.setTranslatesAutoresizingMaskIntoConstraints_(False) 693 | width_constraint = text_field.widthAnchor().constraintEqualToConstant_(size[0]) 694 | width_constraint.setActive_(True) 695 | height_constraint = text_field.heightAnchor().constraintEqualToConstant_(size[1]) 696 | height_constraint.setActive_(True) 697 | if placeholder: 698 | text_field.setPlaceholderString_(placeholder) 699 | if target: 700 | text_field.setTarget_(target) 701 | if action: 702 | text_field.setAction_(action) 703 | 704 | return text_field 705 | 706 | 707 | ################################################################################ 708 | # Menus 709 | ################################################################################ 710 | 711 | 712 | def menu_bar() -> AppKit.NSMenuItem: 713 | """Create the app's menu bar""" 714 | menu = menu_with_submenu(None) 715 | NSApp.setMainMenu_(menu) 716 | return menu 717 | 718 | 719 | def menu_main() -> AppKit.NSMenu: 720 | """Return app's main menu""" 721 | return NSApp.mainMenu() 722 | 723 | 724 | def menu_with_submenu( 725 | title: str | None = None, parent: AppKit.NSMenu | None = None 726 | ) -> AppKit.NSMenu: 727 | """Create a menu with a submenu""" 728 | if title: 729 | menu = AppKit.NSMenu.alloc().initWithTitle_(title) 730 | else: 731 | menu = AppKit.NSMenu.alloc().init() 732 | sub_menu = menu_item(title) 733 | sub_menu.setSubmenu_(menu) 734 | if parent: 735 | parent.addItem_(sub_menu) 736 | return menu 737 | 738 | 739 | def menu_item( 740 | title: str | None, 741 | parent: AppKit.NSMenu | None = None, 742 | target: NSObject | None = None, 743 | action: Callable | str | None = None, 744 | key: str | None = None, 745 | ) -> AppKit.NSMenuItem: 746 | """Create a menu item and optionally add it to a parent menu""" 747 | key = key or "" 748 | title = title or "" 749 | item = AppKit.NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( 750 | title, action, key 751 | ) 752 | if target: 753 | item.setTarget_(target) 754 | if parent: 755 | parent.addItem_(item) 756 | return item 757 | 758 | 759 | @dataclass 760 | class MenuItem: 761 | title: str 762 | target: NSObject | None = None 763 | action: Callable | str | None = None 764 | key: str | None = None 765 | 766 | 767 | def menus_from_dict( 768 | menus: dict[str, Iterable[MenuItem | dict]], 769 | target: NSObject | None = None, 770 | parent: AppKit.NSMenu | None = None, 771 | ) -> dict[str, list[AppKit.NSMenu | dict]]: 772 | """Create menus from a dict 773 | 774 | Args: 775 | menus: dict of 776 | target: the default target object for menu items (for example, window class's self) 777 | parent: the parent menu; if None, uses the app's top-level menu as parent 778 | 779 | Returns: 780 | dict of menus and their children 781 | 782 | Note: 783 | target may be specified in the target argument and will be used as the default target for all menu items 784 | unless the menu item specifies a different target in the MenuItem.target field. 785 | .When calling this from your app, leave parent = None to add the menu items to the app's top-level menu 786 | """ 787 | top_level_menus = {} 788 | parent = parent or menu_main() 789 | for title, value in menus.items(): 790 | top_menu = menu_with_submenu(title, parent) 791 | top_level_menus[title] = [top_menu] 792 | if isinstance(value, Iterable): 793 | for item in value: 794 | if isinstance(item, dict): 795 | top_level_menus[title].append( 796 | menus_from_dict(item, target, top_menu) 797 | ) 798 | else: 799 | child_item = menu_item( 800 | title=item.title, 801 | parent=top_menu, 802 | action=item.action, 803 | target=item.target or target, 804 | key=item.key, 805 | ) 806 | top_level_menus[title].append({item.title: child_item}) 807 | return top_level_menus 808 | 809 | 810 | ################################################################################ 811 | # Utility Functions 812 | ################################################################################ 813 | 814 | 815 | def min_with_index(values: list[float]) -> tuple[int, int]: 816 | """Return the minimum value and index of the minimum value in a list""" 817 | min_value = min(values) 818 | min_index = values.index(min_value) 819 | return min_value, min_index 820 | 821 | 822 | def nsdate_to_datetime(nsdate: NSDate): 823 | """Convert an NSDate to a datetime in the specified timezone 824 | 825 | Args: 826 | nsdate: NSDate to convert 827 | 828 | Returns: naive datetime.datetime 829 | 830 | Note: timezone is the identifier of the timezone to convert to, e.g. "America/New_York" or "US/Eastern" 831 | """ 832 | # NSDate's reference date is 2001-01-01 00:00:00 +0000 833 | reference_date = datetime.datetime(2001, 1, 1, tzinfo=datetime.timezone.utc) 834 | seconds_since_ref = nsdate.timeIntervalSinceReferenceDate() 835 | dt = reference_date + datetime.timedelta(seconds=seconds_since_ref) 836 | # all NSDates are naive; use local timezone to adjust from UTC to local 837 | timezone = NSTimeZone.localTimeZone().name() 838 | try: 839 | tz = zoneinfo.ZoneInfo(timezone) 840 | except zoneinfo.ZoneInfoNotFoundError: 841 | raise ValueError(f"Invalid timezone: {timezone}") 842 | 843 | dt = dt.astimezone(tz=tz) 844 | return dt.replace(tzinfo=None) 845 | 846 | 847 | ################################################################################ 848 | # Constraint helper functions 849 | ################################################################################ 850 | 851 | 852 | def set_hugging_priority( 853 | view: NSView, 854 | priority: float = AppKit.NSLayoutPriorityDefaultHigh, 855 | orientation: int = AppKit.NSLayoutConstraintOrientationHorizontal, 856 | ): 857 | """Set content hugging priority for a view""" 858 | view.setContentHuggingPriority_forOrientation_( 859 | priority, 860 | orientation, 861 | ) 862 | 863 | 864 | def set_compression_resistance( 865 | view: NSView, 866 | priority: float = AppKit.NSLayoutPriorityDefaultHigh, 867 | orientation: int = AppKit.NSLayoutConstraintOrientationHorizontal, 868 | ): 869 | """Set content compression resistance for a view""" 870 | view.setContentCompressionResistancePriority_forOrientation_(priority, orientation) 871 | 872 | 873 | def constrain_stacks_side_by_side( 874 | *stacks: NSStackView, 875 | weights: list[float] | None = None, 876 | parent: NSStackView | None = None, 877 | padding: int = 0, 878 | edge_inset: float = 0, 879 | ): 880 | """Constrain a list of NSStackViews to be side by side optionally using weighted widths 881 | 882 | Args: 883 | *stacks: NSStackViews to constrain 884 | weights: optional weights to use for each stack 885 | parent: NSStackView to constrain the stacks to; if None, uses stacks[0].superview() 886 | padding: padding between stacks 887 | edge_inset: padding between stacks and parent 888 | 889 | 890 | Note: 891 | If weights are provided, the stacks will be constrained to be side by side with 892 | widths proportional to the weights. For example, if 2 stacks are provided with 893 | weights = [1, 2], the first stack will be half the width of the second stack. 894 | """ 895 | 896 | if len(stacks) < 2: 897 | raise ValueError("Must provide at least two stacks") 898 | 899 | parent = parent or stacks[0].superview() 900 | 901 | if weights is not None: 902 | min_weight, min_index = min_with_index(weights) 903 | else: 904 | min_weight, min_index = 1.0, 0 905 | 906 | for i, stack in enumerate(stacks): 907 | if i == 0: 908 | stack.leadingAnchor().constraintEqualToAnchor_constant_( 909 | parent.leadingAnchor(), edge_inset 910 | ).setActive_(True) 911 | else: 912 | stack.leadingAnchor().constraintEqualToAnchor_constant_( 913 | stacks[i - 1].trailingAnchor(), padding 914 | ).setActive_(True) 915 | if i == len(stacks) - 1: 916 | stack.trailingAnchor().constraintEqualToAnchor_constant_( 917 | parent.trailingAnchor(), -edge_inset 918 | ).setActive_(True) 919 | stack.topAnchor().constraintEqualToAnchor_constant_( 920 | parent.topAnchor(), edge_inset 921 | ).setActive_(True) 922 | stack.bottomAnchor().constraintEqualToAnchor_constant_( 923 | parent.bottomAnchor(), -edge_inset 924 | ).setActive_(True) 925 | 926 | if not weights: 927 | continue 928 | 929 | weight = weights[i] / min_weight 930 | 931 | AppKit.NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( 932 | stack, 933 | AppKit.NSLayoutAttributeWidth, 934 | AppKit.NSLayoutRelationEqual, 935 | stacks[min_index], 936 | AppKit.NSLayoutAttributeWidth, 937 | weight, 938 | 0.0, 939 | ).setActive_( 940 | True 941 | ) 942 | 943 | 944 | def constrain_stacks_top_to_bottom( 945 | *stacks: NSStackView, 946 | weights: list[float] | None = None, 947 | parent: NSStackView | None = None, 948 | padding: int = 0, 949 | edge_inset: float = 0, 950 | ): 951 | """Constrain a list of NSStackViews to be top to bottom optionally using weighted widths 952 | 953 | Args: 954 | *stacks: NSStackViews to constrain 955 | weights: optional weights to use for each stack 956 | parent: NSStackView to constrain the stacks to; if None, uses stacks[0].superview() 957 | padding: padding between stacks 958 | edge_inset: padding between stacks and parent 959 | 960 | 961 | Note: 962 | If weights are provided, the stacks will be constrained to be top to bottom with 963 | widths proportional to the weights. For example, if 2 stacks are provided with 964 | weights = [1, 2], the first stack will be half the width of the second stack. 965 | """ 966 | 967 | if len(stacks) < 2: 968 | raise ValueError("Must provide at least two stacks") 969 | 970 | parent = parent or stacks[0].superview() 971 | 972 | if weights is not None: 973 | min_weight, min_index = min_with_index(weights) 974 | else: 975 | min_weight, min_index = 1.0, 0 976 | 977 | for i, stack in enumerate(stacks): 978 | if i == 0: 979 | stack.topAnchor().constraintEqualToAnchor_constant_( 980 | parent.topAnchor(), edge_inset 981 | ).setActive_(True) 982 | else: 983 | stack.topAnchor().constraintEqualToAnchor_constant_( 984 | stacks[i - 1].bottomAnchor(), padding 985 | ).setActive_(True) 986 | if i == len(stacks) - 1: 987 | stack.bottomAnchor().constraintEqualToAnchor_constant_( 988 | parent.bottomAnchor(), -edge_inset 989 | ).setActive_(True) 990 | stack.leadingAnchor().constraintEqualToAnchor_constant_( 991 | parent.leadingAnchor(), edge_inset 992 | ).setActive_(True) 993 | stack.trailingAnchor().constraintEqualToAnchor_constant_( 994 | parent.trailingAnchor(), -edge_inset 995 | ).setActive_(True) 996 | 997 | if not weights: 998 | continue 999 | 1000 | weight = weights[i] / min_weight 1001 | 1002 | AppKit.NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( 1003 | stack, 1004 | AppKit.NSLayoutAttributeHeight, 1005 | AppKit.NSLayoutRelationEqual, 1006 | stacks[min_index], 1007 | AppKit.NSLayoutAttributeHeight, 1008 | weight, 1009 | 0.0, 1010 | ).setActive_( 1011 | True 1012 | ) 1013 | 1014 | 1015 | def constrain_to_parent_width( 1016 | view: NSView, parent: NSView | None = None, edge_inset: float = 0 1017 | ): 1018 | """Constrain an NSView to the width of its parent 1019 | 1020 | Args: 1021 | view: NSView to constrain 1022 | parent: NSView to constrain the control to; if None, uses view.superview() 1023 | edge_inset: margin between control and parent 1024 | """ 1025 | parent = parent or view.superview() 1026 | view.rightAnchor().constraintEqualToAnchor_constant_( 1027 | parent.rightAnchor(), -edge_inset 1028 | ).setActive_(True) 1029 | view.leftAnchor().constraintEqualToAnchor_constant_( 1030 | parent.leftAnchor(), edge_inset 1031 | ).setActive_(True) 1032 | 1033 | 1034 | def constrain_to_width(view: NSView, width: float | None = None): 1035 | """Constrain an NSView to a fixed width 1036 | 1037 | Args: 1038 | view: NSView to constrain 1039 | width: width to constrain to; if None, does not apply a width constraint 1040 | """ 1041 | if width is not None: 1042 | view.widthAnchor().constraintEqualToConstant_(width).setActive_(True) 1043 | 1044 | 1045 | def constrain_to_height(view: NSView, height: float | None = None): 1046 | """Constrain an NSView to a fixed height 1047 | 1048 | Args: 1049 | view: NSView to constrain 1050 | height: height to constrain to; if None, does not apply a height constraint 1051 | """ 1052 | if height is not None: 1053 | view.heightAnchor().constraintEqualToConstant_(height).setActive_(True) 1054 | 1055 | 1056 | def constrain_center_x_to_parent(view: NSView, parent: NSView | None = None): 1057 | """Constrain an NSView to the center of its parent along the x-axis 1058 | 1059 | Args: 1060 | view: NSView to constrain 1061 | parent: NSView to constrain the control to; if None, uses view.superview() 1062 | """ 1063 | parent = parent or view.superview() 1064 | view.centerXAnchor().constraintEqualToAnchor_(parent.centerXAnchor()).setActive_( 1065 | True 1066 | ) 1067 | 1068 | 1069 | def constrain_center_y_to_parent(view: NSView, parent: NSView | None = None): 1070 | """Constrain an NSView to the center of its parent along the y-axis 1071 | 1072 | Args: 1073 | view: NSView to constrain 1074 | parent: NSView to constrain the control to; if None, uses view.superview() 1075 | """ 1076 | parent = parent or view.superview() 1077 | view.centerYAnchor().constraintEqualToAnchor_(parent.centerYAnchor()).setActive_( 1078 | True 1079 | ) 1080 | 1081 | 1082 | def constrain_trailing_anchor_to_parent( 1083 | view: NSView, parent: NSView | None = None, edge_inset: float = EDGE_INSET 1084 | ): 1085 | """Constrain an NSView's trailing anchor to it's parent 1086 | 1087 | Args: 1088 | view: NSView to constrain 1089 | parent: NSView to constrain the control to; if None, uses view.superview() 1090 | inset: inset from trailing edge to apply to constraint (inset will be subtracted from trailing edge) 1091 | """ 1092 | parent = parent or view.superview() 1093 | view.trailingAnchor().constraintEqualToAnchor_constant_( 1094 | parent.trailingAnchor(), -edge_inset 1095 | ).setActive_(True) 1096 | -------------------------------------------------------------------------------- /src/confirmation_window.py: -------------------------------------------------------------------------------- 1 | """Display a window with text detection contents before copying to clipboard""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import AppKit 8 | import objc 9 | from AppKit import NSObject, NSWindow 10 | from Foundation import NSLog 11 | from objc import python_method 12 | 13 | import appkitgui as gui 14 | from pasteboard import Pasteboard 15 | 16 | if TYPE_CHECKING: 17 | from textinator import Textinator 18 | 19 | # constants 20 | EDGE_INSET = 20 21 | EDGE_INSETS = (EDGE_INSET, EDGE_INSET, EDGE_INSET, EDGE_INSET) 22 | PADDING = 8 23 | WINDOW_WIDTH = 500 24 | WINDOW_HEIGHT = 600 25 | 26 | 27 | class ConfirmationWindow(NSObject): 28 | """Confirmation Window to confirm text before copying to clipboard""" 29 | 30 | def init(self): 31 | """Initialize the ConfirmationWindow""" 32 | self = objc.super(ConfirmationWindow, self).init() 33 | if self is None: 34 | return None 35 | return self 36 | 37 | @python_method 38 | def create_window(self) -> NSWindow: 39 | """Create the NSWindow object""" 40 | # use @python_method decorator to tell objc this is called using python 41 | # conventions, not objc conventions 42 | self.window = gui.window( 43 | "Textinator", 44 | (WINDOW_WIDTH, WINDOW_HEIGHT), 45 | mask=AppKit.NSWindowStyleMaskTitled | AppKit.NSWindowStyleMaskClosable, 46 | ) 47 | self.main_view = gui.main_view( 48 | self.window, padding=PADDING, edge_inset=EDGE_INSETS 49 | ) 50 | 51 | self.text_view = gui.text_view( 52 | size=(WINDOW_WIDTH - 2 * EDGE_INSET, WINDOW_HEIGHT - 50) 53 | ) 54 | self.main_view.append(self.text_view) 55 | gui.constrain_to_parent_width(self.text_view, edge_inset=EDGE_INSET) 56 | self.hstack = gui.hstack(align=AppKit.NSLayoutAttributeCenterY) 57 | self.main_view.append(self.hstack) 58 | self.button_cancel = gui.button("Cancel", self, self.buttonCancel_) 59 | self.button_copy = gui.button( 60 | "Copy to clipboard", self, self.buttonCopyToClipboard_ 61 | ) 62 | self.button_copy.setKeyEquivalent_("\r") # Return key 63 | self.button_copy.setKeyEquivalentModifierMask_(0) # No modifier keys 64 | self.hstack.extend([self.button_cancel, self.button_copy]) 65 | gui.constrain_trailing_anchor_to_parent(self.hstack, edge_inset=EDGE_INSET) 66 | 67 | @python_method 68 | def show(self, text: str, app: Textinator): 69 | """Create and show the window""" 70 | 71 | if not hasattr(self, "window"): 72 | self.create_window() 73 | 74 | self.app = app 75 | self.log = app.log 76 | 77 | with objc.autorelease_pool(): 78 | self.log(f"Showing confirmation window with text: {text}") 79 | self.text_view.setString_(text) 80 | self.window.makeKeyAndOrderFront_(None) 81 | self.window.setIsVisible_(True) 82 | self.window.setLevel_(AppKit.NSFloatingWindowLevel + 1) 83 | self.window.setReleasedWhenClosed_(False) 84 | self.window.makeFirstResponder_(self.button_copy) 85 | return self.window 86 | 87 | def buttonCancel_(self, sender): 88 | """Cancel button action""" 89 | self.log("Cancel button clicked, closing window without copying text") 90 | self.window.close() 91 | 92 | def buttonCopyToClipboard_(self, sender): 93 | """Copy to clipboard button action""" 94 | text = self.text_view.string() 95 | self.log(f"Text to copy: {text}") 96 | if self.app.append.state: 97 | clipboard_text = ( 98 | self.app.pasteboard.paste() if self.app.pasteboard.has_text() else "" 99 | ) 100 | clipboard_text = f"{clipboard_text}\n{text}" if clipboard_text else text 101 | else: 102 | clipboard_text = text 103 | self.log(f"Setting clipboard text to: {clipboard_text}") 104 | self.app.pasteboard.copy(clipboard_text) 105 | self.window.close() 106 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/src/icon.png -------------------------------------------------------------------------------- /src/icon_paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/src/icon_paused.png -------------------------------------------------------------------------------- /src/loginitems.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with System Preferences > Users & Groups > Login Items on macOS.""" 2 | 3 | from typing import List 4 | 5 | import applescript 6 | 7 | __all__ = ["add_login_item", "list_login_items", "remove_login_item"] 8 | 9 | # The following functions are used to manipulate the Login Items list in System Preferences 10 | # To use these, your app must include the com.apple.security.automation.apple-events entitlement 11 | # in its entitlements file during signing and must have the NSAppleEventsUsageDescription key in 12 | # its Info.plist file 13 | # These functions use AppleScript to interact with System Preferences. I know of no other way to 14 | # do this programmatically from Python. If you know of a better way, please let me know! 15 | 16 | 17 | def add_login_item(app_name: str, app_path: str, hidden: bool = False): 18 | """Add app to login items""" 19 | scpt = ( 20 | 'tell application "System Events" to make login item at end with properties ' 21 | + f'{{name:"{app_name}", path:"{app_path}", hidden:{"true" if hidden else "false"}}}' 22 | ) 23 | applescript.AppleScript(scpt).run() 24 | 25 | 26 | def remove_login_item(app_name: str): 27 | """Remove app from login items""" 28 | scpt = f'tell application "System Events" to delete login item "{app_name}"' 29 | applescript.AppleScript(scpt).run() 30 | 31 | 32 | def list_login_items() -> List[str]: 33 | """Return list of login items""" 34 | scpt = 'tell application "System Events" to get the name of every login item' 35 | return applescript.AppleScript(scpt).run() 36 | -------------------------------------------------------------------------------- /src/macvision.py: -------------------------------------------------------------------------------- 1 | """Use macOS Vision API to detect text and QR codes in images""" 2 | 3 | from typing import List, Optional, Tuple 4 | 5 | import objc 6 | import Quartz 7 | import Vision 8 | from Foundation import NSURL, NSDictionary, NSLog 9 | 10 | from utils import get_mac_os_version 11 | 12 | __all__ = [ 13 | "ciiimage_from_file", 14 | "detect_qrcodes_in_ciimage", 15 | "detect_qrcodes_in_file", 16 | "detect_text_in_ciimage", 17 | "detect_text_in_file", 18 | "get_supported_vision_languages", 19 | ] 20 | 21 | 22 | def get_supported_vision_languages() -> Tuple[Tuple[str], Tuple[str]]: 23 | """Get supported languages for text detection from Vision framework. 24 | 25 | Returns: Tuple of ((language code), (error)) 26 | """ 27 | 28 | with objc.autorelease_pool(): 29 | revision = Vision.VNRecognizeTextRequestRevision1 30 | if get_mac_os_version() >= ("11", "0", "0"): 31 | revision = Vision.VNRecognizeTextRequestRevision2 32 | 33 | if get_mac_os_version() < ("12", "0", "0"): 34 | return Vision.VNRecognizeTextRequest.supportedRecognitionLanguagesForTextRecognitionLevel_revision_error_( 35 | Vision.VNRequestTextRecognitionLevelAccurate, revision, None 36 | ) 37 | 38 | results = [] 39 | handler = make_request_handler(results) 40 | textRequest = Vision.VNRecognizeTextRequest.alloc().initWithCompletionHandler_( 41 | handler 42 | ) 43 | return textRequest.supportedRecognitionLanguagesAndReturnError_(None) 44 | 45 | 46 | def ciimage_from_file(filepath: str) -> Quartz.CIImage: 47 | """Create a Quartz.CIImage from a file 48 | 49 | Args: 50 | filepath: path to the image file 51 | 52 | Returns: 53 | Quartz.CIImage 54 | """ 55 | with objc.autorelease_pool(): 56 | input_url = NSURL.fileURLWithPath_(filepath) 57 | return Quartz.CIImage.imageWithContentsOfURL_(input_url) 58 | 59 | 60 | def detect_text_in_file( 61 | img_path: str, 62 | orientation: Optional[int] = None, 63 | languages: Optional[List[str]] = None, 64 | ) -> List[Tuple[str, float]]: 65 | """process image file at img_path with VNRecognizeTextRequest and return list of results 66 | 67 | Args: 68 | img_path: path to the image file 69 | orientation: optional EXIF orientation (if known, passing orientation may improve quality of results) 70 | languages: optional languages to use for text detection as list of ISO language code strings; default is ["en-US"] 71 | 72 | Returns: 73 | List of results where each result is a list of [text, confidence] 74 | """ 75 | input_image = ciimage_from_file(img_path) 76 | return detect_text_in_ciimage(input_image, orientation, languages) 77 | 78 | 79 | def detect_text_in_ciimage( 80 | image: Quartz.CIImage, 81 | orientation: Optional[int] = None, 82 | languages: Optional[List[str]] = None, 83 | ) -> List[Tuple[str, float]]: 84 | """process CIImage with VNRecognizeTextRequest and return list of results 85 | 86 | This code originally developed for https://github.com/RhetTbull/osxphotos 87 | 88 | Args: 89 | image: CIIImage to process 90 | orientation: optional EXIF orientation (if known, passing orientation may improve quality of results) 91 | languages: optional languages to use for text detection as list of ISO language code strings; default is ["en-US"] 92 | 93 | Returns: 94 | List of results where each result is a list of [text, confidence] 95 | """ 96 | with objc.autorelease_pool(): 97 | vision_options = NSDictionary.dictionaryWithDictionary_({}) 98 | if orientation is None: 99 | vision_handler = ( 100 | Vision.VNImageRequestHandler.alloc().initWithCIImage_options_( 101 | image, vision_options 102 | ) 103 | ) 104 | elif 1 <= orientation <= 8: 105 | vision_handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_orientation_options_( 106 | image, orientation, vision_options 107 | ) 108 | else: 109 | raise ValueError("orientation must be between 1 and 8") 110 | results = [] 111 | handler = make_request_handler(results) 112 | vision_request = ( 113 | Vision.VNRecognizeTextRequest.alloc().initWithCompletionHandler_(handler) 114 | ) 115 | languages = languages or ["en-US"] 116 | vision_request.setRecognitionLanguages_(languages) 117 | vision_request.setUsesLanguageCorrection_(True) 118 | success, error = vision_handler.performRequests_error_([vision_request], None) 119 | if not success: 120 | raise ValueError(f"Vision request failed: {error}") 121 | 122 | return [(str(result[0]), float(result[1])) for result in results] 123 | 124 | 125 | def make_request_handler(results): 126 | """results: list to store results""" 127 | if not isinstance(results, list): 128 | raise ValueError("results must be a list") 129 | 130 | def handler(request, error): 131 | if error: 132 | NSLog(f"Error! {error}") 133 | else: 134 | observations = request.results() 135 | for text_observation in observations: 136 | recognized_text = text_observation.topCandidates_(1)[0] 137 | results.append([recognized_text.string(), recognized_text.confidence()]) 138 | 139 | return handler 140 | 141 | 142 | def detect_qrcodes_in_file(img_path: str) -> List[str]: 143 | """Detect QR Codes in image files using CIDetector and return text of the found QR Codes 144 | 145 | Args: 146 | img_path: path to the image file 147 | 148 | Returns: 149 | List of QR Code payload texts found in the image 150 | """ 151 | 152 | input_image = ciimage_from_file(img_path) 153 | return detect_qrcodes_in_ciimage(input_image) 154 | 155 | 156 | def detect_qrcodes_in_ciimage(image: Quartz.CIImage) -> List[str]: 157 | """Detect QR Codes in image using CIDetector and return text of the found QR Codes 158 | 159 | Args: 160 | input_image: CIImage to process 161 | 162 | Returns: 163 | List of QR Code payload texts found in the image 164 | """ 165 | 166 | with objc.autorelease_pool(): 167 | context = Quartz.CIContext.contextWithOptions_(None) 168 | options = NSDictionary.dictionaryWithDictionary_( 169 | {"CIDetectorAccuracy": Quartz.CIDetectorAccuracyHigh} 170 | ) 171 | detector = Quartz.CIDetector.detectorOfType_context_options_( 172 | Quartz.CIDetectorTypeQRCode, context, options 173 | ) 174 | 175 | results = [] 176 | features = detector.featuresInImage_(image) 177 | 178 | if not features: 179 | return [] 180 | for idx in range(features.count()): 181 | feature = features.objectAtIndex_(idx) 182 | results.append(feature.messageString()) 183 | return results 184 | -------------------------------------------------------------------------------- /src/pasteboard.py: -------------------------------------------------------------------------------- 1 | """macOS Pasteboard/Clipboard access using native APIs 2 | 3 | Author: Rhet Turnbull 4 | 5 | License: MIT License, copyright 2022 Rhet Turnbull 6 | 7 | Original Source: https://github.com/RhetTbull/textinator 8 | 9 | Version: 1.1.0, 2022-10-26 10 | """ 11 | 12 | import os 13 | import typing as t 14 | 15 | from AppKit import ( 16 | NSPasteboard, 17 | NSPasteboardTypePNG, 18 | NSPasteboardTypeString, 19 | NSPasteboardTypeTIFF, 20 | ) 21 | from Foundation import NSData 22 | 23 | # shortcuts for types 24 | PNG = "PNG" 25 | TIFF = "TIFF" 26 | 27 | __all__ = ["Pasteboard", "PasteboardTypeError", "PNG", "TIFF"] 28 | 29 | 30 | class PasteboardError(Exception): 31 | """Base class for Pasteboard exceptions""" 32 | 33 | ... 34 | 35 | 36 | class PasteboardTypeError(PasteboardError): 37 | """Invalid type specified""" 38 | 39 | ... 40 | 41 | 42 | class Pasteboard: 43 | """macOS Pasteboard/Clipboard Class""" 44 | 45 | def __init__(self): 46 | self.pasteboard = NSPasteboard.generalPasteboard() 47 | self._change_count = self.pasteboard.changeCount() 48 | 49 | def copy(self, text): 50 | """Copy text to clipboard 51 | 52 | Args: 53 | text (str): Text to copy to clipboard 54 | """ 55 | self.set_text(text) 56 | 57 | def paste(self): 58 | """Retrieve text from clipboard 59 | 60 | Returns: str 61 | """ 62 | return self.get_text() 63 | 64 | def append(self, text: str): 65 | """Append text to clipboard 66 | 67 | Args: 68 | text (str): Text to append to clipboard 69 | """ 70 | new_text = self.get_text() + text 71 | self.set_text(new_text) 72 | 73 | def clear(self): 74 | """Clear Clipboard""" 75 | self.pasteboard.clearContents() 76 | self._change_count = self.pasteboard.changeCount() 77 | 78 | def copy_image(self, filename: t.Union[str, os.PathLike], format: str): 79 | """Copy image to clipboard from filename 80 | 81 | Args: 82 | filename (os.PathLike): Filename of image to copy to clipboard 83 | format (str): Format of image to copy, "PNG" or "TIFF" 84 | """ 85 | if not isinstance(filename, str): 86 | filename = str(filename) 87 | self.set_image(filename, format) 88 | 89 | def paste_image( 90 | self, 91 | filename: t.Union[str, os.PathLike], 92 | format: str, 93 | overwrite: bool = False, 94 | ): 95 | """Paste image from clipboard to filename in PNG format 96 | 97 | Args: 98 | filename (os.PathLike): Filename of image to paste to 99 | format (str): Format of image to paste, "PNG" or "TIFF" 100 | overwrite (bool): Overwrite existing file 101 | 102 | Raises: 103 | FileExistsError: If file exists and overwrite is False 104 | """ 105 | if not isinstance(filename, str): 106 | filename = str(filename) 107 | self.get_image(filename, format, overwrite) 108 | 109 | def set_text(self, text: str): 110 | """Set text on clipboard 111 | 112 | Args: 113 | text (str): Text to set on clipboard 114 | """ 115 | self.pasteboard.clearContents() 116 | self.pasteboard.setString_forType_(text, NSPasteboardTypeString) 117 | self._change_count = self.pasteboard.changeCount() 118 | 119 | def get_text(self) -> str: 120 | """Return text from clipboard 121 | 122 | Returns: str 123 | """ 124 | return self.pasteboard.stringForType_(NSPasteboardTypeString) or "" 125 | 126 | def get_image( 127 | self, 128 | filename: t.Union[str, os.PathLike], 129 | format: str, 130 | overwrite: bool = False, 131 | ): 132 | """Save image from clipboard to filename in PNG format 133 | 134 | Args: 135 | filename (os.PathLike): Filename of image to save to 136 | format (str): Format of image to save, "PNG" or "TIFF" 137 | overwrite (bool): Overwrite existing file 138 | 139 | Raises: 140 | FileExistsError: If file exists and overwrite is False 141 | PasteboardTypeError: If format is not "PNG" or "TIFF" 142 | """ 143 | if format not in (PNG, TIFF): 144 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 145 | 146 | if not isinstance(filename, str): 147 | filename = str(filename) 148 | 149 | if not overwrite and os.path.exists(filename): 150 | raise FileExistsError(f"File '{filename}' already exists") 151 | 152 | data = self.get_image_data(format) 153 | data.writeToFile_atomically_(filename, True) 154 | 155 | def set_image(self, filename: t.Union[str, os.PathLike], format: str): 156 | """Set image on clipboard from file in either PNG or TIFF format 157 | 158 | Args: 159 | filename (os.PathLike): Filename of image to set on clipboard 160 | format (str): Format of image to set, "PNG" or "TIFF" 161 | """ 162 | if not isinstance(filename, str): 163 | filename = str(filename) 164 | data = NSData.dataWithContentsOfFile_(filename) 165 | self.set_image_data(data, format) 166 | 167 | def get_image_data(self, format: str) -> NSData: 168 | """Return image data from clipboard as NSData in PNG or TIFF format 169 | 170 | Args: 171 | format (str): Format of image to return, "PNG" or "TIFF" 172 | 173 | Returns: NSData of image in PNG or TIFF format 174 | 175 | Raises: 176 | PasteboardTypeError if clipboard does not contain image in the specified type or type is invalid 177 | """ 178 | if format not in (PNG, TIFF): 179 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 180 | 181 | pb_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF 182 | if pb_type == NSPasteboardTypePNG and not self._has_png(): 183 | raise PasteboardTypeError("Clipboard does not contain PNG image") 184 | return self.pasteboard.dataForType_(pb_type) 185 | 186 | def set_image_data(self, image_data: NSData, format: str): 187 | """Set image data on clipboard from NSData in a supported image format 188 | 189 | Args: 190 | image_data (NSData): Image data to set on clipboard 191 | format (str): Format of image to set, "PNG" or "TIFF" 192 | 193 | Raises: PasteboardTypeError if format is not "PNG" or "TIFF" 194 | """ 195 | if format not in (PNG, TIFF): 196 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 197 | 198 | format_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF 199 | self.pasteboard.clearContents() 200 | self.pasteboard.setData_forType_(image_data, format_type) 201 | self._change_count = self.pasteboard.changeCount() 202 | 203 | def set_text_and_image( 204 | self, text: str, filename: t.Union[str, os.PathLike], format: str 205 | ): 206 | """Set both text from str and image from file in either PNG or TIFF format 207 | 208 | Args: 209 | text (str): Text to set on clipboard 210 | filename (os.PathLike): Filename of image to set on clipboard 211 | format (str): Format of image to set, "PNG" or "TIFF" 212 | """ 213 | if not isinstance(filename, str): 214 | filename = str(filename) 215 | data = NSData.dataWithContentsOfFile_(filename) 216 | self.set_text_and_image_data(text, data, format) 217 | 218 | def set_text_and_image_data(self, text: str, image_data: NSData, format: str): 219 | """Set both text and image data on clipboard from NSData in a supported image format 220 | 221 | Args: 222 | text (str): Text to set on clipboard 223 | image_data (NSData): Image data to set on clipboard 224 | format (str): Format of image to set, "PNG" or "TIFF" 225 | 226 | Raises: PasteboardTypeError if format is not "PNG" or "TIFF" 227 | """ 228 | self.set_image_data(image_data, format) 229 | self.pasteboard.setString_forType_(text, NSPasteboardTypeString) 230 | self._change_count = self.pasteboard.changeCount() 231 | 232 | def has_changed(self) -> bool: 233 | """Return True if clipboard has been changed by another process since last check 234 | 235 | Returns: bool 236 | """ 237 | if self.pasteboard.changeCount() != self._change_count: 238 | self._change_count = self.pasteboard.changeCount() 239 | return True 240 | return False 241 | 242 | def has_image(self, format: t.Optional[str] = None) -> bool: 243 | """Return True if clipboard has image otherwise False 244 | 245 | Args: 246 | format (str): Format of image to check for, "PNG" or "TIFF" or None to check for any image 247 | 248 | Returns: 249 | True if clipboard has image otherwise False 250 | 251 | Raises: 252 | PasteboardTypeError if format is not "PNG" or "TIFF" 253 | """ 254 | if format is None: 255 | return self.pasteboard.types().containsObject_( 256 | NSPasteboardTypeTIFF 257 | ) or self.pasteboard.types().containsObject_(NSPasteboardTypePNG) 258 | elif format == PNG: 259 | return self._has_png() 260 | elif format == TIFF: 261 | return self._has_tiff() 262 | else: 263 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 264 | 265 | def has_text(self) -> bool: 266 | """Return True if clipboard has text, otherwise False 267 | 268 | Returns: bool 269 | """ 270 | return self.pasteboard.types().containsObject_(NSPasteboardTypeString) 271 | 272 | def _has_png(self) -> bool: 273 | """Return True if clipboard can paste PNG image otherwise False 274 | 275 | Returns: bool 276 | """ 277 | return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypePNG])) 278 | 279 | def _has_tiff(self) -> bool: 280 | """Return True if clipboard can paste TIFF image otherwise False 281 | 282 | Returns: bool 283 | """ 284 | return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypeTIFF])) 285 | -------------------------------------------------------------------------------- /src/textinator.py: -------------------------------------------------------------------------------- 1 | """Simple MacOS menu bar / status bar app that automatically perform text detection on screenshots. 2 | 3 | Also detects text on clipboard images and image files via the Services menu. 4 | 5 | Runs on Catalina (10.15) and later. 6 | """ 7 | 8 | import contextlib 9 | import datetime 10 | import plistlib 11 | import typing as t 12 | 13 | import objc 14 | import Quartz 15 | import rumps 16 | from AppKit import NSApplication, NSPasteboardTypeFileURL 17 | from Foundation import ( 18 | NSURL, 19 | NSLog, 20 | NSMetadataQuery, 21 | NSMetadataQueryDidFinishGatheringNotification, 22 | NSMetadataQueryDidStartGatheringNotification, 23 | NSMetadataQueryDidUpdateNotification, 24 | NSMetadataQueryGatheringProgressNotification, 25 | NSNotificationCenter, 26 | NSObject, 27 | NSPredicate, 28 | NSString, 29 | NSUTF8StringEncoding, 30 | ) 31 | 32 | from confirmation_window import ConfirmationWindow 33 | from loginitems import add_login_item, list_login_items, remove_login_item 34 | from macvision import ( 35 | ciimage_from_file, 36 | detect_qrcodes_in_ciimage, 37 | detect_text_in_ciimage, 38 | get_supported_vision_languages, 39 | ) 40 | from pasteboard import TIFF, Pasteboard 41 | from utils import get_app_path, get_screenshot_location, verify_directory_access 42 | 43 | # do not manually change the version; use bump2version per the README 44 | __version__ = "0.10.1" 45 | 46 | APP_NAME = "Textinator" 47 | APP_ICON = "icon.png" 48 | APP_ICON_PAUSED = "icon_paused.png" 49 | 50 | # default confidence threshold for text detection 51 | CONFIDENCE = {"LOW": 0.3, "MEDIUM": 0.5, "HIGH": 0.8} 52 | CONFIDENCE_DEFAULT = "LOW" 53 | 54 | # default language for text detection 55 | LANGUAGE_DEFAULT = "en-US" 56 | LANGUAGE_ENGLISH = "en-US" 57 | 58 | # where to store saved state, will reside in Application Support/APP_NAME 59 | CONFIG_FILE = f"{APP_NAME}.plist" 60 | 61 | # optional logging to file if debug enabled (will always log to Console via NSLog) 62 | LOG_FILE = f"{APP_NAME}.log" 63 | 64 | # how often (in seconds) to check for new screenshots on the clipboard 65 | CLIPBOARD_CHECK_INTERVAL = 2 66 | 67 | 68 | class Textinator(rumps.App): 69 | """MacOS Menu Bar App to automatically perform text detection on screenshots.""" 70 | 71 | def __init__(self, *args, **kwargs): 72 | super(Textinator, self).__init__(*args, **kwargs) 73 | 74 | # set "debug" to true in the config file to enable debug logging 75 | self._debug = False 76 | 77 | # pause / resume text detection 78 | self._paused = False 79 | 80 | # set the icon to a PNG file in the current directory 81 | # this immediately updates the menu bar icon 82 | # py2app will place the icon in the app bundle Resources folder 83 | self.icon = APP_ICON 84 | 85 | # ensure icon matches menu bar dark/light state 86 | self.template = True 87 | 88 | # the log method uses NSLog to log to the unified log 89 | self.log("started") 90 | 91 | # get list of supported languages for language menu 92 | languages, _ = get_supported_vision_languages() 93 | languages = languages or [LANGUAGE_DEFAULT] 94 | self.log(f"supported languages: {languages}") 95 | self.recognition_language = ( 96 | LANGUAGE_DEFAULT if LANGUAGE_DEFAULT in languages else languages[0] 97 | ) 98 | 99 | # menus 100 | self.confidence = rumps.MenuItem("Text Detection Confidence Threshold") 101 | self.confidence_low = rumps.MenuItem("Low", self.on_confidence) 102 | self.confidence_medium = rumps.MenuItem("Medium", self.on_confidence) 103 | self.confidence_high = rumps.MenuItem("High", self.on_confidence) 104 | self.language = rumps.MenuItem("Text Recognition Language") 105 | for language in languages: 106 | self.language.add(rumps.MenuItem(language, self.on_language)) 107 | self.language_english = rumps.MenuItem("Always Detect English", self.on_toggle) 108 | self.detect_clipboard = rumps.MenuItem( 109 | "Detect Text in Images on Clipboard", self.on_toggle 110 | ) 111 | self.qrcodes = rumps.MenuItem("Detect QR Codes", self.on_toggle) 112 | self.pause = rumps.MenuItem("Pause Text Detection", self.on_pause) 113 | self.show_notification = rumps.MenuItem("Notification", self.on_toggle) 114 | self.linebreaks = rumps.MenuItem("Keep Linebreaks", self.on_toggle) 115 | self.append = rumps.MenuItem("Append to Clipboard", self.on_toggle) 116 | self.clear_clipboard = rumps.MenuItem( 117 | "Clear Clipboard", self.on_clear_clipboard 118 | ) 119 | self.confirmation = rumps.MenuItem("Confirm Clipboard Changes", self.on_toggle) 120 | self.show_last_detetection = rumps.MenuItem( 121 | "Show Last Text Detection", self.on_show_last_detection 122 | ) 123 | self.start_on_login = rumps.MenuItem( 124 | f"Start {APP_NAME} on Login", self.on_start_on_login 125 | ) 126 | self.about = rumps.MenuItem(f"About {APP_NAME}", self.on_about) 127 | self.quit = rumps.MenuItem(f"Quit {APP_NAME}", self.on_quit) 128 | self.menu = [ 129 | [ 130 | self.confidence, 131 | [self.confidence_low, self.confidence_medium, self.confidence_high], 132 | ], 133 | self.language, 134 | self.language_english, 135 | self.detect_clipboard, 136 | self.pause, 137 | None, 138 | self.qrcodes, 139 | None, 140 | self.show_notification, 141 | None, 142 | self.linebreaks, 143 | self.append, 144 | self.clear_clipboard, 145 | self.confirmation, 146 | self.show_last_detetection, 147 | None, 148 | self.start_on_login, 149 | self.about, 150 | self.quit, 151 | ] 152 | 153 | # load config from plist file and init menu state 154 | self.load_config() 155 | 156 | # set icon to auto switch between light and dark mode 157 | self.template = True 158 | 159 | # track all screenshots already seen 160 | self._screenshots = {} 161 | 162 | # Need to verify access to the screenshot folder; default is ~/Desktop 163 | # When this is called for the first time, the user will be prompted to grant access 164 | # and shown the message assigned to NSDesktopFolderUsageDescription in the Info.plist file 165 | self.verify_screenshot_access() 166 | 167 | # initialize the service provider class which handles actions from the Services menu 168 | # pass reference to self so the service provider can access the app's methods and state 169 | self.service_provider = ServiceProvider.alloc().initWithApp_(self) 170 | # register the service provider with the Services menu 171 | NSApplication.sharedApplication().setServicesProvider_(self.service_provider) 172 | 173 | # Create a Pasteboard instance which will be used by clipboard_watcher() to detect changes 174 | # to the pasteboard (which everyone but Apple calls the clipboard) 175 | self.pasteboard = Pasteboard() 176 | 177 | # will hold ConfirmationWindow if needed 178 | self.confirmation_window = None 179 | 180 | # last detected text is stored 181 | self.last_detected_text = None 182 | 183 | # start the spotlight query 184 | self.start_query() 185 | 186 | def log(self, msg: str): 187 | """Log a message to unified log.""" 188 | NSLog(f"{APP_NAME} {__version__} {msg}") 189 | 190 | # if debug set in config, also log to file 191 | # file will be created in Application Support folder 192 | if self._debug: 193 | with self.open(LOG_FILE, "a") as f: 194 | f.write(f"{datetime.datetime.now().isoformat()} - {msg}\n") 195 | 196 | def verify_screenshot_access(self): 197 | """Verify screenshot access and alert user if needed""" 198 | if screenshot_location := get_screenshot_location(): 199 | if verify_directory_access(screenshot_location): 200 | self.log(f"screenshot location access ok: {screenshot_location}") 201 | else: 202 | self.log( 203 | f"Error: could not access default screenshot location {screenshot_location}" 204 | ) 205 | rumps.alert( 206 | f"Error: {APP_NAME} could not access the default screenshot location {screenshot_location} \n" 207 | f"You may need to enable Full Disk Access for {APP_NAME} in System Settings...>Privacy & Security> Full Disk Access" 208 | ) 209 | else: 210 | self.log(f"Error: could not determine default screenshot location") 211 | rumps.alert( 212 | f"Error: {APP_NAME} could not determine the default screenshot location. " 213 | ) 214 | 215 | def load_config(self): 216 | """Load config from plist file in Application Support folder. 217 | 218 | The usual app convention is to store config in ~/Library/Preferences but 219 | rumps.App.open() provides a convenient self.open() method to access the 220 | Application Support folder so that's what is used here. 221 | 222 | The config info is saved as a plist file (property list) which is an Apple standard 223 | for storing structured data. JSON or another format could be used but I stuck with 224 | plist so that the config file could be easily edited manually if needed and that's 225 | what is expected by macOS apps. 226 | """ 227 | self.config = {} 228 | with contextlib.suppress(FileNotFoundError): 229 | with self.open(CONFIG_FILE, "rb") as f: 230 | with contextlib.suppress(Exception): 231 | # don't crash if config file is malformed 232 | self.config = plistlib.load(f) 233 | if not self.config: 234 | # file didn't exist or was malformed, create a new one 235 | # initialize config with default values 236 | self.config = { 237 | "confidence": CONFIDENCE_DEFAULT, 238 | "linebreaks": True, 239 | "append": False, 240 | "notification": True, 241 | "language": self.recognition_language, 242 | "always_detect_english": True, 243 | "detect_qrcodes": False, 244 | "start_on_login": False, 245 | "confirmation": False, 246 | "detect_clipboard": True, 247 | } 248 | self.log(f"loaded config: {self.config}") 249 | 250 | # update the menu state to match the loaded config 251 | self.append.state = self.config.get("append", False) 252 | self.linebreaks.state = self.config.get("linebreaks", True) 253 | self.show_notification.state = self.config.get("notification", True) 254 | self.set_confidence_state(self.config.get("confidence", CONFIDENCE_DEFAULT)) 255 | self.recognition_language = self.config.get( 256 | "language", self.recognition_language 257 | ) 258 | self.set_language_menu_state(self.recognition_language) 259 | self.language_english.state = self.config.get("always_detect_english", True) 260 | self.detect_clipboard.state = self.config.get("detect_clipboard", True) 261 | self.confirmation.state = self.config.get("confirmation", False) 262 | self.qrcodes.state = self.config.get("detect_qrcodes", False) 263 | self._debug = self.config.get("debug", False) 264 | self.start_on_login.state = self.config.get("start_on_login", False) 265 | 266 | # save config because it may have been updated with default values 267 | self.save_config() 268 | 269 | def save_config(self): 270 | """Write config to plist file in Application Support folder. 271 | 272 | See docstring on load_config() for additional information. 273 | """ 274 | self.config["linebreaks"] = self.linebreaks.state 275 | self.config["append"] = self.append.state 276 | self.config["notification"] = self.show_notification.state 277 | self.config["confidence"] = self.get_confidence_state() 278 | self.config["language"] = self.recognition_language 279 | self.config["always_detect_english"] = self.language_english.state 280 | self.config["detect_clipboard"] = self.detect_clipboard.state 281 | self.config["confirmation"] = self.confirmation.state 282 | self.config["detect_qrcodes"] = self.qrcodes.state 283 | self.config["debug"] = self._debug 284 | self.config["start_on_login"] = self.start_on_login.state 285 | with self.open(CONFIG_FILE, "wb+") as f: 286 | plistlib.dump(self.config, f) 287 | self.log(f"saved config: {self.config}") 288 | 289 | def on_language(self, sender): 290 | """Change language.""" 291 | self.recognition_language = sender.title 292 | self.set_language_menu_state(sender.title) 293 | self.save_config() 294 | 295 | def on_pause(self, sender): 296 | """Pause/resume text detection.""" 297 | if self._paused: 298 | self._paused = False 299 | self.icon = APP_ICON 300 | sender.title = "Pause Text Detection" 301 | else: 302 | self._paused = True 303 | self.icon = APP_ICON_PAUSED 304 | sender.title = "Resume text detection" 305 | 306 | def on_toggle(self, sender): 307 | """Toggle sender state.""" 308 | sender.state = not sender.state 309 | self.save_config() 310 | 311 | def on_clear_clipboard(self, sender): 312 | """Clear the clipboard""" 313 | self.pasteboard.clear() 314 | 315 | def on_confidence(self, sender): 316 | """Change confidence threshold.""" 317 | self.clear_confidence_state() 318 | sender.state = True 319 | self.save_config() 320 | 321 | def on_show_last_detection(self, sender): 322 | """Show last detected text""" 323 | self.confirmation_window = ( 324 | self.confirmation_window or ConfirmationWindow.alloc().init() 325 | ) 326 | self.confirmation_window.show(self.last_detected_text or "", self) 327 | 328 | def clear_confidence_state(self): 329 | """Clear confidence menu state""" 330 | self.confidence_low.state = False 331 | self.confidence_medium.state = False 332 | self.confidence_high.state = False 333 | 334 | def get_confidence_state(self): 335 | """Get confidence threshold state.""" 336 | if self.confidence_low.state: 337 | return "LOW" 338 | elif self.confidence_medium.state: 339 | return "MEDIUM" 340 | elif self.confidence_high.state: 341 | return "HIGH" 342 | else: 343 | return CONFIDENCE_DEFAULT 344 | 345 | def set_confidence_state(self, confidence): 346 | """Set confidence threshold state.""" 347 | self.clear_confidence_state() 348 | if confidence == "LOW": 349 | self.confidence_low.state = True 350 | elif confidence == "MEDIUM": 351 | self.confidence_medium.state = True 352 | elif confidence == "HIGH": 353 | self.confidence_high.state = True 354 | else: 355 | raise ValueError(f"Unknown confidence threshold: {confidence}") 356 | 357 | def set_language_menu_state(self, language): 358 | """Set the language menu state""" 359 | for item in self.language.values(): 360 | item.state = False 361 | if item.title == language: 362 | item.state = True 363 | 364 | def on_start_on_login(self, sender): 365 | """Configure app to start on login or toggle this setting.""" 366 | self.start_on_login.state = not self.start_on_login.state 367 | if self.start_on_login.state: 368 | app_path = get_app_path() 369 | self.log(f"adding app to login items with path {app_path}") 370 | if APP_NAME not in list_login_items(): 371 | add_login_item(APP_NAME, app_path, hidden=False) 372 | else: 373 | self.log("removing app from login items") 374 | if APP_NAME in list_login_items(): 375 | remove_login_item(APP_NAME) 376 | self.save_config() 377 | 378 | def on_about(self, sender): 379 | """Display about dialog.""" 380 | rumps.alert( 381 | title=f"About {APP_NAME}", 382 | message=f"{APP_NAME} Version {__version__}\n\n" 383 | f"{APP_NAME} is a simple utility to recognize text in screenshots.\n\n" 384 | f"{APP_NAME} is open source and licensed under the MIT license.\n\n" 385 | "Copyright 2022 by Rhet Turnbull\n" 386 | "https://github.com/RhetTbull/textinator", 387 | ok="OK", 388 | ) 389 | 390 | def on_quit(self, sender): 391 | """Cleanup before quitting.""" 392 | self.log("quitting") 393 | NSNotificationCenter.defaultCenter().removeObserver_(self) 394 | self.query.stopQuery() 395 | self.query.setDelegate_(None) 396 | self.query.release() 397 | rumps.quit_application() 398 | 399 | def start_query(self): 400 | """Start the NSMetdataQuery Spotlight query to monitor for screenshot files.""" 401 | self.query = NSMetadataQuery.alloc().init() 402 | 403 | # screenshots all have metadata property kMDItemIsScreenCapture set to 1 404 | # this can be viewed with the command line tool mdls 405 | self.query.setPredicate_( 406 | NSPredicate.predicateWithFormat_("kMDItemIsScreenCapture = 1") 407 | ) 408 | 409 | # configure the query to post notifications, which our query_updated method will handle 410 | nf = NSNotificationCenter.defaultCenter() 411 | nf.addObserver_selector_name_object_( 412 | self, 413 | "query_updated:", 414 | None, 415 | self.query, 416 | ) 417 | self.query.setDelegate_(self) 418 | self.query.startQuery() 419 | 420 | def initialize_screenshots(self, notif): 421 | """Track all screenshots already seen or that existed on app startup. 422 | 423 | The Spotlight query will return *all* screenshots on the computer so track those results 424 | when returned and only process new screenshots. 425 | """ 426 | results = notif.object().results() 427 | for item in results: 428 | path = item.valueForAttribute_( 429 | "kMDItemPath" 430 | ).stringByResolvingSymlinksInPath() 431 | self._screenshots[path] = True 432 | 433 | def process_screenshot(self, notif): 434 | """Process a new screenshot and detect text (and QR codes if requested).""" 435 | results = notif.object().results() 436 | for item in results: 437 | path = item.valueForAttribute_( 438 | "kMDItemPath" 439 | ).stringByResolvingSymlinksInPath() 440 | 441 | if path in self._screenshots: 442 | # we've already seen this screenshot or screenshot existed at app startup, skip it 443 | continue 444 | 445 | if self._paused: 446 | # don't process screenshots if paused but still add to seen list 447 | self.log(f"skipping screenshot because app is paused: {path}") 448 | self._screenshots[path] = "__SKIPPED__" 449 | continue 450 | 451 | self.log(f"processing new screenshot: {path}") 452 | 453 | screenshot_image = ciimage_from_file(path) 454 | if screenshot_image is None: 455 | self.log(f"failed to load screenshot image: {path}") 456 | continue 457 | 458 | detected_text = self.process_image(screenshot_image) 459 | self._screenshots[path] = detected_text 460 | if self.show_notification.state: 461 | self.notification( 462 | title="Processed Screenshot", 463 | subtitle=f"{path}", 464 | message=( 465 | f"Detected text: {detected_text}" 466 | if detected_text 467 | else "No text detected" 468 | ), 469 | ) 470 | 471 | def process_image(self, image: Quartz.CIImage) -> str: 472 | """Process an image and detect text (and QR codes if requested). 473 | Updates the clipboard with the detected text. 474 | 475 | Args: 476 | image: Quartz.CIImage 477 | 478 | Returns: 479 | String of detected text or empty string if no text detected. 480 | """ 481 | # if "Always Detect English" checked, add English to list of languages to detect 482 | languages = ( 483 | [self.recognition_language, LANGUAGE_ENGLISH] 484 | if self.language_english.state 485 | and self.recognition_language != LANGUAGE_ENGLISH 486 | else [self.recognition_language] 487 | ) 488 | detected_text = detect_text_in_ciimage(image, languages=languages) 489 | confidence = CONFIDENCE[self.get_confidence_state()] 490 | text = "\n".join( 491 | result[0] for result in detected_text if result[1] >= confidence 492 | ) 493 | 494 | if self.qrcodes.state: 495 | # Also detect QR codes and copy the text from the QR code payload 496 | if detected_qrcodes := detect_qrcodes_in_ciimage(image): 497 | text = ( 498 | text + "\n" + "\n".join(detected_qrcodes) 499 | if text 500 | else "\n".join(detected_qrcodes) 501 | ) 502 | 503 | if text: 504 | if not self.linebreaks.state: 505 | text = text.replace("\n", " ") 506 | self.last_detected_text = text 507 | 508 | if self.append.state: 509 | clipboard_text = ( 510 | self.pasteboard.paste() if self.pasteboard.has_text() else "" 511 | ) 512 | clipboard_text = f"{clipboard_text}\n{text}" if clipboard_text else text 513 | else: 514 | clipboard_text = text 515 | 516 | if self.confirmation.state: 517 | # display confirmation dialog 518 | verb = "Append" if self.append.state else "Copy" 519 | self.confirmation_window = ( 520 | self.confirmation_window or ConfirmationWindow.alloc().init() 521 | ) 522 | self.confirmation_window.show(text, self) 523 | else: 524 | self.pasteboard.copy(clipboard_text) 525 | 526 | return text 527 | 528 | def query_updated_(self, notif): 529 | """Receives and processes notifications from the Spotlight query. 530 | The trailing _ in the name is required by PyObjC to conform to Objective-C calling conventions. 531 | Reference: https://pyobjc.readthedocs.io/en/latest/core/intro.html#underscores-and-lots-of-them 532 | """ 533 | if notif.name() == NSMetadataQueryDidStartGatheringNotification: 534 | # The query has just started 535 | self.log("search: query started") 536 | elif notif.name() == NSMetadataQueryDidFinishGatheringNotification: 537 | # The query has just finished 538 | # log all results so we don't try to do text detection on previous screenshots 539 | self.log("search: finished gathering") 540 | self.initialize_screenshots(notif) 541 | elif notif.name() == NSMetadataQueryGatheringProgressNotification: 542 | # The query is still gathering results... 543 | self.log("search: gathering progress") 544 | elif notif.name() == NSMetadataQueryDidUpdateNotification: 545 | # There's a new result available 546 | self.log("search: an update happened.") 547 | self.process_screenshot(notif) 548 | 549 | @rumps.timer(CLIPBOARD_CHECK_INTERVAL) 550 | def clipboard_watcher(self, sender): 551 | """Watch the clipboard (pasteboard) for changes. 552 | Uses rumps.timer decorator to run every CLIPBOARD_CHECK_INTERVAL seconds. 553 | The timer runs even if detect_clipboard is not checked or app is paused 554 | but won't process images in those cases. 555 | """ 556 | if not self.detect_clipboard.state: 557 | return 558 | 559 | if self.pasteboard.has_changed() and self.pasteboard.has_image(): 560 | # image is on the pasteboard, process it 561 | self.log("new image on clipboard") 562 | if self.pasteboard.has_text(): 563 | # some apps like Excel copy an image representation of the text to the clipboard 564 | # in addition to the text, in this case do not do text detection, see #16 565 | self.log("clipboard has text, skipping") 566 | return 567 | if self._paused: 568 | self.log("skipping clipboard image because app is paused") 569 | return 570 | self.process_clipboard_image() 571 | 572 | def process_clipboard_image(self): 573 | """Process the image on the clipboard.""" 574 | if image_data := self.pasteboard.get_image_data(TIFF): 575 | image = Quartz.CIImage.imageWithData_(image_data) 576 | detected_text = self.process_image(image) 577 | self.log("processed clipboard image") 578 | if self.show_notification.state: 579 | self.notification( 580 | title="Processed Clipboard Image", 581 | subtitle="", 582 | message=( 583 | f"Detected text: {detected_text}" 584 | if detected_text 585 | else "No text detected" 586 | ), 587 | ) 588 | else: 589 | self.log("failed to get image data from pasteboard") 590 | 591 | def notification(self, title, subtitle, message): 592 | """Display a notification.""" 593 | self.log(f"notification: {title} - {subtitle} - {message}") 594 | rumps.notification(title, subtitle, message) 595 | 596 | 597 | def serviceSelector(fn): 598 | """Decorator to convert a method to a selector to handle an NSServices message.""" 599 | return objc.selector(fn, signature=b"v@:@@o^@") 600 | 601 | 602 | def ErrorValue(e): 603 | """Handler for errors returned by the service.""" 604 | NSLog(f"{APP_NAME} {__version__} error: {e}") 605 | return e 606 | 607 | 608 | class ServiceProvider(NSObject): 609 | """Service provider class to handle messages from the Services menu 610 | 611 | Initialize with ServiceProvider.alloc().initWithApp_(app) 612 | """ 613 | 614 | app: t.Optional[Textinator] = None 615 | 616 | def initWithApp_(self, app: Textinator): 617 | self = objc.super(ServiceProvider, self).init() 618 | self.app = app 619 | return self 620 | 621 | @serviceSelector 622 | def detectTextInImage_userData_error_( 623 | self, pasteboard, userdata, error 624 | ) -> t.Optional[str]: 625 | """Detect text in an image on the clipboard. 626 | 627 | This method will be called by the Services menu when the user selects "Detect Text With Textinator". 628 | It is specified in the setup.py NSMessage attribute. The method name in NSMessage is `detectTextInImage` 629 | but the actual Objective-C signature is `detectTextInImage:userData:error:` hence the matching underscores 630 | in the python method name. 631 | 632 | Args: 633 | pasteboard: NSPasteboard object containing the URLs of the image files to process 634 | userdata: Unused, passed by the Services menu as value of NSUserData attribute in setup.py; 635 | can be used to pass additional data to the service if needed 636 | error: Unused; in Objective-C, error is a pointer to an NSError object that will be set if an error occurs; 637 | when using pyobjc, errors are returned as str values and the actual error argument is ignored. 638 | 639 | Returns: 640 | error: str value containing the error message if an error occurs, otherwise None 641 | 642 | Note: because this method is explicitly invoked by the user via the Services menu, it will 643 | be called and files processed even if the app is paused. 644 | 645 | """ 646 | self.app.log("detectTextInImage_userData_error_ called via Services menu") 647 | 648 | try: 649 | for item in pasteboard.pasteboardItems(): 650 | # pasteboard will contain one or more URLs to image files passed by the Services menu 651 | pb_url_data = item.dataForType_(NSPasteboardTypeFileURL) 652 | pb_url = NSURL.URLWithString_( 653 | NSString.alloc().initWithData_encoding_( 654 | pb_url_data, NSUTF8StringEncoding 655 | ) 656 | ) 657 | self.app.log(f"processing file from Services menu: {pb_url.path()}") 658 | image = Quartz.CIImage.imageWithContentsOfURL_(pb_url) 659 | detected_text = self.app.process_image(image) 660 | if self.app.show_notification.state: 661 | self.app.notification( 662 | title="Processed Image", 663 | subtitle=f"{pb_url.path()}", 664 | message=( 665 | f"Detected text: {detected_text}" 666 | if detected_text 667 | else "No text detected" 668 | ), 669 | ) 670 | except Exception as e: 671 | return ErrorValue(e) 672 | 673 | return None 674 | 675 | 676 | if __name__ == "__main__": 677 | Textinator(name=APP_NAME, quit_button=None).run() 678 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | """macOS specific utilities used by Textinator""" 2 | 3 | import os 4 | import platform 5 | from typing import Tuple 6 | 7 | import objc 8 | from Foundation import ( 9 | NSURL, 10 | NSBundle, 11 | NSDesktopDirectory, 12 | NSFileManager, 13 | NSLog, 14 | NSUserDefaults, 15 | NSUserDomainMask, 16 | ) 17 | 18 | __all__ = [ 19 | "get_app_path", 20 | "get_mac_os_version", 21 | "get_screenshot_location", 22 | "verify_directory_access", 23 | "verify_screenshot_access", 24 | ] 25 | 26 | 27 | def verify_directory_access(path: str) -> str | None: 28 | """Verify that the app has access to the specified directory 29 | 30 | Args: 31 | path: str path to the directory to verify access to. 32 | 33 | Returns: path if access is verified, None otherwise. 34 | """ 35 | with objc.autorelease_pool(): 36 | path_url = NSURL.fileURLWithPath_(path) 37 | ( 38 | directory_files, 39 | error, 40 | ) = NSFileManager.defaultManager().contentsOfDirectoryAtURL_includingPropertiesForKeys_options_error_( 41 | path_url, [], 0, None 42 | ) 43 | if error: 44 | NSLog(f"verify_directory_access: {error.localizedDescription()}") 45 | return None 46 | return path 47 | 48 | 49 | def get_screenshot_location() -> str: 50 | """Return path to the default location for screenshots 51 | 52 | First checks the custom screenshot location from com.apple.screencapture. 53 | If not set or inaccessible, assumes Desktop. 54 | 55 | If the App has NSDesktopFolderUsageDescription set in Info.plist, 56 | user will be prompted to grant Desktop access the first time this is run 57 | if the screenshot location is the Desktop. 58 | 59 | Returns: str path to the screenshot location. 60 | """ 61 | with objc.autorelease_pool(): 62 | # Check for custom screenshot location 63 | screencapture_defaults = NSUserDefaults.alloc().initWithSuiteName_( 64 | "com.apple.screencapture" 65 | ) 66 | if custom_location := screencapture_defaults.stringForKey_("location"): 67 | return os.path.expanduser(custom_location) 68 | 69 | # Fallback to Desktop if no custom location or if it's inaccessible 70 | ( 71 | desktop_url, 72 | error, 73 | ) = NSFileManager.defaultManager().URLForDirectory_inDomain_appropriateForURL_create_error_( 74 | NSDesktopDirectory, NSUserDomainMask, None, False, None 75 | ) 76 | return str(desktop_url.path()) if not error else os.path.expanduser("~/Desktop") 77 | 78 | 79 | def verify_screenshot_access() -> str | None: 80 | """Verify that the app has access to the user's screenshot location or Desktop 81 | 82 | First checks the custom screenshot location from com.apple.screencapture. 83 | If not set or inaccessible, checks the Desktop. 84 | 85 | If the App has NSDesktopFolderUsageDescription set in Info.plist, 86 | user will be prompted to grant Desktop access the first time this is run. 87 | 88 | Returns: path to screenshot location if access otherwise None 89 | """ 90 | with objc.autorelease_pool(): 91 | screenshot_location = get_screenshot_location() 92 | return verify_directory_access(screenshot_location) 93 | 94 | 95 | def get_mac_os_version() -> Tuple[str, str, str]: 96 | """Returns tuple of str in form (version, major, minor) containing OS version, e.g. 10.13.6 = ("10", "13", "6")""" 97 | version = platform.mac_ver()[0].split(".") 98 | if len(version) == 2: 99 | (ver, major) = version 100 | minor = "0" 101 | elif len(version) == 3: 102 | (ver, major, minor) = version 103 | else: 104 | raise ( 105 | ValueError( 106 | f"Could not parse version string: {platform.mac_ver()} {version}" 107 | ) 108 | ) 109 | 110 | # python might return 10.16 instead of 11.0 for Big Sur and above 111 | if ver == "10" and int(major) >= 16: 112 | ver = str(11 + int(major) - 16) 113 | major = minor 114 | minor = "0" 115 | 116 | return (ver, major, minor) 117 | 118 | 119 | def get_app_path() -> str: 120 | """Return path to the bundle containing this script""" 121 | # Note: This must be called from an app bundle built with py2app or you'll get 122 | # the path of the python interpreter instead of the actual app 123 | return NSBundle.mainBundle().bundlePath() 124 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration for pytest for Textinator tests.""" 2 | 3 | import os 4 | import pathlib 5 | import shutil 6 | import tempfile 7 | import time 8 | import typing as t 9 | from contextlib import contextmanager 10 | from io import TextIOWrapper 11 | 12 | import applescript 13 | import CoreServices 14 | import pytest 15 | from applescript import kMissingValue 16 | from osxmetadata.mditem import set_mditem_metadata 17 | 18 | from .loginitems import add_login_item, list_login_items, remove_login_item 19 | from .pasteboard import Pasteboard 20 | 21 | 22 | def click_menu_item(menu_item: str, sub_menu_item: t.Optional[str] = None) -> bool: 23 | """Click menu_item in Textinator's status bar menu. 24 | 25 | This uses AppleScript and System Events to click on the menu item. 26 | 27 | Args: 28 | menu_item: Name of menu item to click. 29 | sub_menu_item: Name of sub menu item to click or None if no sub menu item. 30 | 31 | Returns: 32 | True if menu item was successfully clicked, False otherwise. 33 | 34 | Note: in many status bar apps, the actual menu bar you want to click is menu bar 2; 35 | menu bar 1 is the Apple menu. In RUMPS apps, it appears that the menu bar you want is 36 | menu bar 1. This may be different for other apps. 37 | """ 38 | scpt = applescript.AppleScript( 39 | """ 40 | on click_menu_item(process_, menu_item_name_, submenu_item_name_) 41 | try 42 | tell application "System Events" to tell process process_ 43 | tell menu bar item 1 of menu bar 1 44 | click 45 | click menu item menu_item_name_ of menu 1 46 | if submenu_item_name_ is not missing value then 47 | click menu item submenu_item_name_ of menu 1 of menu item menu_item_name_ of menu 1 48 | end if 49 | end tell 50 | end tell 51 | on error 52 | return false 53 | end try 54 | return true 55 | end click_menu_item 56 | """ 57 | ) 58 | sub_menu_item = sub_menu_item or kMissingValue 59 | return_value = scpt.call("click_menu_item", "Textinator", menu_item, sub_menu_item) 60 | time.sleep(5) 61 | return return_value 62 | 63 | 64 | def click_window_button(window: int, button: int) -> bool: 65 | """ "Click a button in a Textinator window. 66 | 67 | Args: 68 | window: window number (1 = first window) 69 | button: button number (1 = first button, if yes/no, 1 = yes, 2 = no) 70 | 71 | Returns: 72 | True if successful, False otherwise 73 | """ 74 | scpt = applescript.AppleScript( 75 | """ 76 | on click_window_button(process_, window_number_, button_number_) 77 | try 78 | tell application "System Events" to tell process process_ 79 | tell button button_number_ of window window_number_ 80 | click 81 | end tell 82 | end tell 83 | on error 84 | return false 85 | end try 86 | return true 87 | end click_window_button 88 | """ 89 | ) 90 | return scpt.call("click_window_button", "Textinator", window, button) 91 | 92 | 93 | def process_is_running(process_name: str) -> bool: 94 | """Return True if process_name is running, False otherwise""" 95 | scpt = applescript.AppleScript( 96 | """ 97 | on process_is_running(process_name_) 98 | tell application "System Events" 99 | set process_list to (name of every process) 100 | end tell 101 | return process_name_ is in process_list 102 | end process_is_running 103 | """ 104 | ) 105 | return scpt.call("process_is_running", process_name) 106 | 107 | 108 | @contextmanager 109 | def copy_to_desktop(filepath): 110 | """Fixture to copy file to Desktop in a temporary directory.""" 111 | filepath = pathlib.Path(filepath) 112 | desktop_path = pathlib.Path("~/Desktop").expanduser() 113 | with tempfile.TemporaryDirectory(dir=desktop_path, prefix="Textinator-") as tempdir: 114 | tempdir_path = pathlib.Path(tempdir) 115 | shutil.copy(filepath, tempdir_path) 116 | yield tempdir_path / filepath.name 117 | 118 | 119 | def mark_screenshot(filepath: t.Union[str, pathlib.Path]) -> bool: 120 | """Mark a file as screenshot so Spotlight will index it. 121 | 122 | Args: 123 | filepath: Fully resolved path to file to mark as screenshot. 124 | 125 | Returns: 126 | True if file was marked as screenshot, False otherwise. 127 | 128 | Note: This uses a private Apple API exposed by osxmetadata to set the appropriate metadata. 129 | """ 130 | filepath = filepath if isinstance(filepath, str) else str(filepath) 131 | mditem = CoreServices.MDItemCreate(None, str(filepath)) 132 | return set_mditem_metadata(mditem, "kMDItemIsScreenCapture", True) 133 | 134 | 135 | @pytest.fixture 136 | def pb(): 137 | """Return pasteboard""" 138 | return Pasteboard() 139 | 140 | 141 | def app_support_dir() -> pathlib.Path: 142 | """Return path to Textinator's app support directory""" 143 | return pathlib.Path("~/Library/Application Support/Textinator").expanduser() 144 | 145 | 146 | @contextmanager 147 | def log_file() -> TextIOWrapper: 148 | """Return Textinator's log file, opened for reading from end""" 149 | log_filepath = app_support_dir() / "Textinator.log" 150 | lf = log_filepath.open("r") 151 | lf.seek(0, os.SEEK_END) 152 | yield lf 153 | lf.close() 154 | 155 | 156 | def backup_log(): 157 | """Backup log file""" 158 | log_path = app_support_dir() / "Textinator.log" 159 | if log_path.exists(): 160 | log_path.rename(log_path.with_suffix(".log.bak")) 161 | 162 | 163 | def restore_log(): 164 | """Restore log file from backup""" 165 | log_path = app_support_dir() / "Textinator.log.bak" 166 | if log_path.exists(): 167 | log_path.rename(log_path.parent / log_path.stem) 168 | 169 | 170 | def backup_plist(): 171 | """Backup plist file""" 172 | plist_path = app_support_dir() / "Textinator.plist" 173 | if plist_path.exists(): 174 | plist_path.rename(plist_path.with_suffix(".plist.bak")) 175 | 176 | 177 | def restore_plist(): 178 | """Restore plist file from backup""" 179 | plist_path = app_support_dir() / "Textinator.plist.bak" 180 | if plist_path.exists(): 181 | plist_path.rename(plist_path.parent / plist_path.stem) 182 | 183 | 184 | @pytest.fixture(autouse=True, scope="session") 185 | def setup_teardown(): 186 | """Fixture to execute asserts before and after test session is run""" 187 | # setup 188 | os.system("killall Textinator") 189 | 190 | # backup_log() 191 | backup_plist() 192 | 193 | shutil.copy("tests/data/Textinator.plist", app_support_dir() / "Textinator.plist") 194 | 195 | login_item = "Textinator" in list_login_items() 196 | if login_item: 197 | remove_login_item("Textinator") 198 | 199 | os.system("open -a Textinator") 200 | time.sleep(5) 201 | 202 | yield # run tests 203 | 204 | # teardown 205 | os.system("killall Textinator") 206 | 207 | # restore_log() 208 | restore_plist() 209 | 210 | if login_item: 211 | add_login_item("Textinator", "/Applications/Textinator.app", False) 212 | 213 | os.system("open -a Textinator") 214 | 215 | 216 | @pytest.fixture 217 | def suspend_capture(pytestconfig): 218 | """Context manager fixture that suspends capture of stdout/stderr for the duration of the context manager.""" 219 | 220 | class suspend_guard: 221 | def __init__(self): 222 | self.capmanager = pytestconfig.pluginmanager.getplugin("capturemanager") 223 | 224 | def __enter__(self): 225 | self.capmanager.suspend_global_capture(in_=True) 226 | 227 | def __exit__(self, _1, _2, _3): 228 | self.capmanager.resume_global_capture() 229 | 230 | yield suspend_guard() 231 | -------------------------------------------------------------------------------- /tests/data/Textinator.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | always_detect_english 6 | 1 7 | append 8 | 0 9 | confidence 10 | LOW 11 | confirmation 12 | 0 13 | debug 14 | 15 | detect_clipboard 16 | 1 17 | detect_qrcodes 18 | 0 19 | language 20 | en-US 21 | linebreaks 22 | 1 23 | notification 24 | 1 25 | start_on_login 26 | 0 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/data/hello.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/hello.png -------------------------------------------------------------------------------- /tests/data/hello_world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/hello_world.png -------------------------------------------------------------------------------- /tests/data/hello_world_linebreaks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/hello_world_linebreaks.png -------------------------------------------------------------------------------- /tests/data/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/qrcode.png -------------------------------------------------------------------------------- /tests/data/qrcode_with_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/qrcode_with_text.png -------------------------------------------------------------------------------- /tests/data/world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RhetTbull/textinator/92444b7a157ffe03bacfd29e07e4c1a9eca1c937/tests/data/world.png -------------------------------------------------------------------------------- /tests/loginitems.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with System Preferences > Users & Groups > Login Items on macOS.""" 2 | 3 | from typing import List 4 | 5 | import applescript 6 | 7 | __all__ = ["add_login_item", "list_login_items", "remove_login_item"] 8 | 9 | # The following functions are used to manipulate the Login Items list in System Preferences 10 | # To use these, your app must include the com.apple.security.automation.apple-events entitlement 11 | # in its entitlements file during signing and must have the NSAppleEventsUsageDescription key in 12 | # its Info.plist file 13 | # These functions use AppleScript to interact with System Preferences. I know of no other way to 14 | # do this programmatically from Python. If you know of a better way, please let me know! 15 | 16 | 17 | def add_login_item(app_name: str, app_path: str, hidden: bool = False): 18 | """Add app to login items""" 19 | scpt = ( 20 | 'tell application "System Events" to make login item at end with properties ' 21 | + f'{{name:"{app_name}", path:"{app_path}", hidden:{"true" if hidden else "false"}}}' 22 | ) 23 | applescript.AppleScript(scpt).run() 24 | 25 | 26 | def remove_login_item(app_name: str): 27 | """Remove app from login items""" 28 | scpt = f'tell application "System Events" to delete login item "{app_name}"' 29 | applescript.AppleScript(scpt).run() 30 | 31 | 32 | def list_login_items() -> List[str]: 33 | """Return list of login items""" 34 | scpt = 'tell application "System Events" to get the name of every login item' 35 | return applescript.AppleScript(scpt).run() 36 | -------------------------------------------------------------------------------- /tests/pasteboard.py: -------------------------------------------------------------------------------- 1 | """macOS Pasteboard/Clipboard access using native APIs 2 | 3 | Author: Rhet Turnbull 4 | 5 | License: MIT License, copyright 2022 Rhet Turnbull 6 | 7 | Original Source: https://github.com/RhetTbull/textinator 8 | 9 | Version: 1.1.0, 2022-10-26 10 | """ 11 | 12 | import os 13 | import typing as t 14 | 15 | from AppKit import ( 16 | NSPasteboard, 17 | NSPasteboardTypePNG, 18 | NSPasteboardTypeString, 19 | NSPasteboardTypeTIFF, 20 | ) 21 | from Foundation import NSData 22 | 23 | # shortcuts for types 24 | PNG = "PNG" 25 | TIFF = "TIFF" 26 | 27 | __all__ = ["Pasteboard", "PasteboardTypeError", "PNG", "TIFF"] 28 | 29 | 30 | class PasteboardError(Exception): 31 | """Base class for Pasteboard exceptions""" 32 | 33 | ... 34 | 35 | 36 | class PasteboardTypeError(PasteboardError): 37 | """Invalid type specified""" 38 | 39 | ... 40 | 41 | 42 | class Pasteboard: 43 | """macOS Pasteboard/Clipboard Class""" 44 | 45 | def __init__(self): 46 | self.pasteboard = NSPasteboard.generalPasteboard() 47 | self._change_count = self.pasteboard.changeCount() 48 | 49 | def copy(self, text): 50 | """Copy text to clipboard 51 | 52 | Args: 53 | text (str): Text to copy to clipboard 54 | """ 55 | self.set_text(text) 56 | 57 | def paste(self): 58 | """Retrieve text from clipboard 59 | 60 | Returns: str 61 | """ 62 | return self.get_text() 63 | 64 | def append(self, text: str): 65 | """Append text to clipboard 66 | 67 | Args: 68 | text (str): Text to append to clipboard 69 | """ 70 | new_text = self.get_text() + text 71 | self.set_text(new_text) 72 | 73 | def clear(self): 74 | """Clear clipboard""" 75 | self.pasteboard.clearContents() 76 | self._change_count = self.pasteboard.changeCount() 77 | 78 | def copy_image(self, filename: t.Union[str, os.PathLike], format: str): 79 | """Copy image to clipboard from filename 80 | 81 | Args: 82 | filename (os.PathLike): Filename of image to copy to clipboard 83 | format (str): Format of image to copy, "PNG" or "TIFF" 84 | """ 85 | if not isinstance(filename, str): 86 | filename = str(filename) 87 | self.set_image(filename, format) 88 | 89 | def paste_image( 90 | self, 91 | filename: t.Union[str, os.PathLike], 92 | format: str, 93 | overwrite: bool = False, 94 | ): 95 | """Paste image from clipboard to filename in PNG format 96 | 97 | Args: 98 | filename (os.PathLike): Filename of image to paste to 99 | format (str): Format of image to paste, "PNG" or "TIFF" 100 | overwrite (bool): Overwrite existing file 101 | 102 | Raises: 103 | FileExistsError: If file exists and overwrite is False 104 | """ 105 | if not isinstance(filename, str): 106 | filename = str(filename) 107 | self.get_image(filename, format, overwrite) 108 | 109 | def set_text(self, text: str): 110 | """Set text on clipboard 111 | 112 | Args: 113 | text (str): Text to set on clipboard 114 | """ 115 | self.pasteboard.clearContents() 116 | self.pasteboard.setString_forType_(text, NSPasteboardTypeString) 117 | self._change_count = self.pasteboard.changeCount() 118 | 119 | def get_text(self) -> str: 120 | """Return text from clipboard 121 | 122 | Returns: str 123 | """ 124 | return self.pasteboard.stringForType_(NSPasteboardTypeString) or "" 125 | 126 | def get_image( 127 | self, 128 | filename: t.Union[str, os.PathLike], 129 | format: str, 130 | overwrite: bool = False, 131 | ): 132 | """Save image from clipboard to filename in PNG format 133 | 134 | Args: 135 | filename (os.PathLike): Filename of image to save to 136 | format (str): Format of image to save, "PNG" or "TIFF" 137 | overwrite (bool): Overwrite existing file 138 | 139 | Raises: 140 | FileExistsError: If file exists and overwrite is False 141 | PasteboardTypeError: If format is not "PNG" or "TIFF" 142 | """ 143 | if format not in (PNG, TIFF): 144 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 145 | 146 | if not isinstance(filename, str): 147 | filename = str(filename) 148 | 149 | if not overwrite and os.path.exists(filename): 150 | raise FileExistsError(f"File '{filename}' already exists") 151 | 152 | data = self.get_image_data(format) 153 | data.writeToFile_atomically_(filename, True) 154 | 155 | def set_image(self, filename: t.Union[str, os.PathLike], format: str): 156 | """Set image on clipboard from file in either PNG or TIFF format 157 | 158 | Args: 159 | filename (os.PathLike): Filename of image to set on clipboard 160 | format (str): Format of image to set, "PNG" or "TIFF" 161 | """ 162 | if not isinstance(filename, str): 163 | filename = str(filename) 164 | data = NSData.dataWithContentsOfFile_(filename) 165 | self.set_image_data(data, format) 166 | 167 | def get_image_data(self, format: str) -> NSData: 168 | """Return image data from clipboard as NSData in PNG or TIFF format 169 | 170 | Args: 171 | format (str): Format of image to return, "PNG" or "TIFF" 172 | 173 | Returns: NSData of image in PNG or TIFF format 174 | 175 | Raises: 176 | PasteboardTypeError if clipboard does not contain image in the specified type or type is invalid 177 | """ 178 | if format not in (PNG, TIFF): 179 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 180 | 181 | pb_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF 182 | if pb_type == NSPasteboardTypePNG and not self._has_png(): 183 | raise PasteboardTypeError("Clipboard does not contain PNG image") 184 | return self.pasteboard.dataForType_(pb_type) 185 | 186 | def set_image_data(self, image_data: NSData, format: str): 187 | """Set image data on clipboard from NSData in a supported image format 188 | 189 | Args: 190 | image_data (NSData): Image data to set on clipboard 191 | format (str): Format of image to set, "PNG" or "TIFF" 192 | 193 | Raises: PasteboardTypeError if format is not "PNG" or "TIFF" 194 | """ 195 | if format not in (PNG, TIFF): 196 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 197 | 198 | format_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF 199 | self.pasteboard.clearContents() 200 | self.pasteboard.setData_forType_(image_data, format_type) 201 | self._change_count = self.pasteboard.changeCount() 202 | 203 | def set_text_and_image( 204 | self, text: str, filename: t.Union[str, os.PathLike], format: str 205 | ): 206 | """Set both text from str and image from file in either PNG or TIFF format 207 | 208 | Args: 209 | text (str): Text to set on clipboard 210 | filename (os.PathLike): Filename of image to set on clipboard 211 | format (str): Format of image to set, "PNG" or "TIFF" 212 | """ 213 | if not isinstance(filename, str): 214 | filename = str(filename) 215 | data = NSData.dataWithContentsOfFile_(filename) 216 | self.set_text_and_image_data(text, data, format) 217 | 218 | def set_text_and_image_data(self, text: str, image_data: NSData, format: str): 219 | """Set both text and image data on clipboard from NSData in a supported image format 220 | 221 | Args: 222 | text (str): Text to set on clipboard 223 | image_data (NSData): Image data to set on clipboard 224 | format (str): Format of image to set, "PNG" or "TIFF" 225 | 226 | Raises: PasteboardTypeError if format is not "PNG" or "TIFF" 227 | """ 228 | self.set_image_data(image_data, format) 229 | self.pasteboard.setString_forType_(text, NSPasteboardTypeString) 230 | self._change_count = self.pasteboard.changeCount() 231 | 232 | def has_changed(self) -> bool: 233 | """Return True if clipboard has been changed by another process since last check 234 | 235 | Returns: bool 236 | """ 237 | if self.pasteboard.changeCount() != self._change_count: 238 | self._change_count = self.pasteboard.changeCount() 239 | return True 240 | return False 241 | 242 | def has_image(self, format: t.Optional[str] = None) -> bool: 243 | """Return True if clipboard has image otherwise False 244 | 245 | Args: 246 | format (str): Format of image to check for, "PNG" or "TIFF" or None to check for any image 247 | 248 | Returns: 249 | True if clipboard has image otherwise False 250 | 251 | Raises: 252 | PasteboardTypeError if format is not "PNG" or "TIFF" 253 | """ 254 | if format is None: 255 | return self.pasteboard.types().containsObject_( 256 | NSPasteboardTypeTIFF 257 | ) or self.pasteboard.types().containsObject_(NSPasteboardTypePNG) 258 | elif format == PNG: 259 | return self._has_png() 260 | elif format == TIFF: 261 | return self._has_tiff() 262 | else: 263 | raise PasteboardTypeError("Invalid format, must be PNG or TIFF") 264 | 265 | def has_text(self) -> bool: 266 | """Return True if clipboard has text, otherwise False 267 | 268 | Returns: bool 269 | """ 270 | return self.pasteboard.types().containsObject_(NSPasteboardTypeString) 271 | 272 | def _has_png(self) -> bool: 273 | """Return True if clipboard can paste PNG image otherwise False 274 | 275 | Returns: bool 276 | """ 277 | return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypePNG])) 278 | 279 | def _has_tiff(self) -> bool: 280 | """Return True if clipboard can paste TIFF image otherwise False 281 | 282 | Returns: bool 283 | """ 284 | return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypeTIFF])) 285 | -------------------------------------------------------------------------------- /tests/test_textinator.py: -------------------------------------------------------------------------------- 1 | """Tests for Textinator""" 2 | 3 | import os 4 | from time import sleep 5 | 6 | from .conftest import ( 7 | click_menu_item, 8 | click_window_button, 9 | copy_to_desktop, 10 | log_file, 11 | mark_screenshot, 12 | process_is_running, 13 | ) 14 | from .loginitems import list_login_items, remove_login_item 15 | 16 | TEST_FILE_HELLO_WORLD = "tests/data/hello_world.png" 17 | TEST_FILE_HELLO_WORLD_LINEBREAK = "tests/data/hello_world_linebreaks.png" 18 | TEST_FILE_HELLO = "tests/data/hello.png" 19 | TEST_FILE_WORLD = "tests/data/world.png" 20 | TEST_QRCODE = "tests/data/qrcode.png" 21 | TEST_QRCODE_WITH_TEXT = "tests/data/qrcode_with_text.png" 22 | 23 | 24 | def test_screenshot_basic(pb): 25 | """Test screenshot detection""" 26 | pb.clear() 27 | with log_file() as log: 28 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 29 | mark_screenshot(filepath) 30 | sleep(5) 31 | assert pb.get_text() == "Hello World" 32 | assert "notification: Processed Screenshot" in log.read() 33 | 34 | 35 | def test_screenshot_linebreak(pb): 36 | """Test screenshot detection with linebreaks""" 37 | pb.clear() 38 | with log_file() as log: 39 | with copy_to_desktop(TEST_FILE_HELLO_WORLD_LINEBREAK) as filepath: 40 | mark_screenshot(filepath) 41 | sleep(5) 42 | assert pb.get_text() == "Hello\nWorld" 43 | assert "notification: Processed Screenshot" in log.read() 44 | 45 | 46 | def test_screenshot_no_notification(pb): 47 | """Test screenshot detection with no notification""" 48 | assert click_menu_item("Notification") 49 | pb.clear() 50 | with log_file() as log: 51 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 52 | mark_screenshot(filepath) 53 | sleep(5) 54 | assert pb.get_text() == "Hello World" 55 | assert "notification:" not in log.read() 56 | # turn notification back on 57 | assert click_menu_item("Notification") 58 | 59 | 60 | def test_screenshot_append(pb): 61 | """Test screenshot detection with append""" 62 | assert click_menu_item("Append to Clipboard") 63 | pb.clear() 64 | with copy_to_desktop(TEST_FILE_HELLO) as filepath: 65 | mark_screenshot(filepath) 66 | sleep(5) 67 | with copy_to_desktop(TEST_FILE_WORLD) as filepath: 68 | mark_screenshot(filepath) 69 | sleep(5) 70 | assert pb.get_text() == "Hello\nWorld" 71 | # turn append off 72 | assert click_menu_item("Append to Clipboard") 73 | 74 | 75 | def test_screenshot_qrcode(pb): 76 | """Test screenshot detection with QR code""" 77 | assert click_menu_item("Detect QR Codes") 78 | # set confidence to high because sometimes the QR code is detected as text 79 | assert click_menu_item("Text Detection Confidence Threshold", "High") 80 | pb.clear() 81 | with copy_to_desktop(TEST_QRCODE) as filepath: 82 | mark_screenshot(filepath) 83 | sleep(5) 84 | assert pb.get_text() == "https://github.com/RhetTbull/textinator" 85 | assert click_menu_item("Detect QR Codes") 86 | assert click_menu_item("Text Detection Confidence Threshold", "Low") 87 | 88 | 89 | def test_screenshot_qrcode_with_text(pb): 90 | """Test screenshot detection with QR code and text""" 91 | assert click_menu_item("Detect QR Codes") 92 | pb.clear() 93 | with copy_to_desktop(TEST_QRCODE_WITH_TEXT) as filepath: 94 | mark_screenshot(filepath) 95 | sleep(5) 96 | text = pb.get_text() 97 | assert "https://github.com/RhetTbull/textinator" in text 98 | assert "SCAN ME" in text 99 | assert click_menu_item("Detect QR Codes") 100 | 101 | 102 | def test_screenshot_qrcode_with_text_no_detect(pb): 103 | """Test screenshot detection with QR code and text when QR code detection is off""" 104 | pb.clear() 105 | with copy_to_desktop(TEST_QRCODE_WITH_TEXT) as filepath: 106 | mark_screenshot(filepath) 107 | sleep(5) 108 | text = pb.get_text() 109 | assert "https://github.com/RhetTbull/textinator" not in text 110 | assert "SCAN ME" in text 111 | 112 | 113 | def test_pause(pb): 114 | """Test pause""" 115 | pb.clear() 116 | pb.set_text("Paused") 117 | assert click_menu_item("Pause Text Detection") 118 | with log_file() as log: 119 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 120 | mark_screenshot(filepath) 121 | sleep(5) 122 | assert pb.get_text() == "Paused" 123 | assert "skipping screenshot because app is paused:" in log.read() 124 | with log_file() as log: 125 | assert click_menu_item("Resume text detection") 126 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 127 | mark_screenshot(filepath) 128 | sleep(5) 129 | assert pb.get_text() == "Hello World" 130 | assert "notification: Processed Screenshot" in log.read() 131 | 132 | 133 | def test_confidence(pb): 134 | """Test text detection confidence menu""" 135 | pb.clear() 136 | with log_file() as log: 137 | assert click_menu_item("Text Detection Confidence Threshold", "Medium") 138 | assert "'confidence': 'MEDIUM'" in log.read() 139 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 140 | mark_screenshot(filepath) 141 | sleep(5) 142 | assert pb.get_text() == "Hello World" 143 | assert click_menu_item("Text Detection Confidence Threshold", "Low") 144 | assert "'confidence': 'LOW'" in log.read() 145 | 146 | 147 | def test_clipboard_basic(pb): 148 | """Test clipboard detection""" 149 | pb.clear() 150 | pb.set_image(TEST_FILE_HELLO_WORLD, "PNG") 151 | sleep(5) 152 | assert pb.get_text() == "Hello World" 153 | 154 | 155 | def test_clipboard_text_and_image(pb): 156 | """Test clipboard detection when clipboard has text and image (#16)""" 157 | pb.clear() 158 | with log_file() as log: 159 | pb.set_text_and_image("Alt Text", TEST_FILE_HELLO_WORLD, "PNG") 160 | sleep(5) 161 | assert "clipboard has text, skipping" in log.read() 162 | assert pb.get_text() == "Alt Text" 163 | 164 | 165 | def test_clipboard_no_clipboard(pb): 166 | """Test clipboard detection does not run when "Detect Text in Images on Clipboard" is off""" 167 | assert click_menu_item("Detect Text in Images on Clipboard") 168 | pb.clear() 169 | pb.set_image(TEST_FILE_HELLO_WORLD, "PNG") 170 | sleep(5) 171 | assert pb.get_text() == "" 172 | assert click_menu_item("Detect Text in Images on Clipboard") 173 | 174 | 175 | def test_clear_clipboard(pb): 176 | """Test Clear Clipboard menu item works""" 177 | pb.set_text("Hello World") 178 | assert click_menu_item("Clear Clipboard") 179 | assert pb.get_text() == "" 180 | 181 | 182 | def test_confirm_clipboard_changes_yes(pb): 183 | """Test Confirm Clipboard Changes menu item works when pressing Yes""" 184 | pb.clear() 185 | with log_file() as log: 186 | assert click_menu_item("Confirm Clipboard Changes") 187 | assert "'confirmation': 1" in log.read() 188 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 189 | mark_screenshot(filepath) 190 | sleep(5) 191 | assert click_window_button(1, 2) # button 1 is Yes 192 | sleep(5) 193 | assert pb.get_text() == "Hello World" 194 | assert click_menu_item("Confirm Clipboard Changes") 195 | 196 | 197 | def test_confirm_clipboard_changes_no(pb): 198 | """Test Confirm Clipboard Changes menu item works when pressing No""" 199 | pb.set_text("Nope") 200 | with log_file() as log: 201 | assert click_menu_item("Confirm Clipboard Changes") 202 | assert "'confirmation': 1" in log.read() 203 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 204 | mark_screenshot(filepath) 205 | sleep(5) 206 | assert click_window_button(1, 1) # button 2 is "No" 207 | sleep(5) 208 | assert pb.get_text() == "Nope" 209 | assert click_menu_item("Confirm Clipboard Changes") 210 | 211 | 212 | 213 | def test_show_last_text_detection(pb): 214 | """Test Show Last Text Detection menu item works""" 215 | pb.clear() 216 | with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath: 217 | mark_screenshot(filepath) 218 | sleep(5) 219 | with log_file() as log: 220 | assert click_menu_item("Show Last Text Detection") 221 | assert "Showing confirmation window" in log.read() 222 | assert click_window_button(1, 2) # button 1 is Yes 223 | sleep(5) 224 | assert pb.get_text() == "Hello World" 225 | assert click_menu_item("Confirm Clipboard Changes") 226 | 227 | 228 | def test_enable_start_on_login(): 229 | """Test Start Textinator on Login menu item works""" 230 | # setup_teardown() should have removed the login item if it existed 231 | assert "Textinator" not in list_login_items() 232 | assert click_menu_item("Start Textinator on Login") 233 | assert "Textinator" in list_login_items() 234 | assert click_menu_item("Start Textinator on Login") 235 | assert "Textinator" not in list_login_items() 236 | 237 | 238 | def test_about(): 239 | """Test About dialog""" 240 | assert click_menu_item("About Textinator") 241 | assert click_window_button(1, 1) 242 | 243 | 244 | def test_quit(): 245 | """Test Quit menu item""" 246 | assert process_is_running("Textinator") 247 | assert click_menu_item("Quit Textinator") 248 | assert not process_is_running("Textinator") 249 | os.system("open -a Textinator") 250 | --------------------------------------------------------------------------------