[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 75,\n  \"badgeTemplate\": \"[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat)](#contributors)\",\n  \"commit\": false,\n  \"commitConvention\": \"angular\",\n  \"contributors\": [\n    {\n      \"login\": \"bwagner\",\n      \"name\": \"Bernhard Wagner\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/447049?v=4\",\n      \"profile\": \"https://github.com/bwagner\",\n      \"contributions\": [\n        \"ideas\",\n        \"code\",\n        \"test\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 7,\n  \"skipCi\": true,\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"projectName\": \"textinator\",\n  \"projectOwner\": \"RhetTbull\",\n  \"commitType\": \"docs\"\n}\n"
  },
  {
    "path": ".bumpversion.cfg",
    "content": "[bumpversion]\ncurrent_version = 0.10.1\nparse = (?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)\nserialize = {major}.{minor}.{patch}\n\n[bumpversion:file:src/textinator.py]\nparse = __version__\\s=\\s\\\"(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)\\\"\nserialize = {major}.{minor}.{patch}\n\n[bumpversion:file:setup.py]\nparse = __version__\\s=\\s\\\"(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)\\\"\nserialize = {major}.{minor}.{patch}\n"
  },
  {
    "path": ".gitignore",
    "content": ".metrics\n.DS_store\n__pycache__\n.coverage\n.condaauto\nt.out\n.vscode/\n.tox/\n.idea/\ndist/\nbuild/\nworking/\n.mypy_cache/\ncli.spec\n*.pyc\ndocsrc/_build/\nvenv/\n.python-version\ncov.xml\n.eggs/\npyrightconfig.json\n"
  },
  {
    "path": ".isort.cfg",
    "content": "[settings]\nprofile=black\nmulti_line_output=3\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# See https://pre-commit.com for more information\n# See https://pre-commit.com/hooks.html for more hooks\nrepos:\n-   repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v3.2.0\n    hooks:\n    -   id: trailing-whitespace\n    -   id: end-of-file-fixer\n    -   id: check-yaml\n    -   id: check-added-large-files\n-   repo: https://github.com/psf/black\n    rev: 22.10.0\n    hooks:\n    -   id: black\n-   repo: https://github.com/pycqa/isort\n    rev: 5.12.0\n    hooks:\n    -   id: isort\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "### Changelog\n\nAll notable changes to this project will be documented in this file. Dates are displayed in UTC.\n\nGenerated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).\n\n#### [v0.10.1](https://github.com/RhetTbull/textinator/compare/v0.10.0...v0.10.1)\n\n> 4 May 2024\n\n- Version bump [`0d8fd5b`](https://github.com/RhetTbull/textinator/commit/0d8fd5b66cf33f6f9f538922e5d16f74585fe93c)\n- Fixed window level [`e3f1708`](https://github.com/RhetTbull/textinator/commit/e3f170835966b386c504e781e2512578acf20eac)\n\n#### [v0.10.0](https://github.com/RhetTbull/textinator/compare/v0.9.2...v0.10.0)\n\n> 4 May 2024\n\n- Feat may 2024 [`#33`](https://github.com/RhetTbull/textinator/pull/33)\n- FIXES: custom locations for screenshots (Issue #29) [`#30`](https://github.com/RhetTbull/textinator/pull/30)\n- Added confirmation window, #18 [`2c0b84b`](https://github.com/RhetTbull/textinator/commit/2c0b84beb24a21f8f1d947f04637fd390a26eab3)\n- Implemented #29 [`b38c918`](https://github.com/RhetTbull/textinator/commit/b38c918f56f12791718c8637c3ea2a35ce68e41a)\n- Add Show Last Detected Text menu, #32 [`d335b5c`](https://github.com/RhetTbull/textinator/commit/d335b5c0be896177384a00b8d0716278074a2097)\n- Added test for show last text detection, #32 [`21214e7`](https://github.com/RhetTbull/textinator/commit/21214e7aece4406f867882d7e0acd875eb40c8c2)\n- Version bump [`52f4c4c`](https://github.com/RhetTbull/textinator/commit/52f4c4c311f0f9445f0e330d891cfda19bc3a0b5)\n\n#### [v0.9.2](https://github.com/RhetTbull/textinator/compare/v0.9.1...v0.9.2)\n\n> 2 December 2023\n\n- ADDS: isort, black pre-commit [`#28`](https://github.com/RhetTbull/textinator/pull/28)\n- FIXES: issue #26: menu entry capitalization [`#27`](https://github.com/RhetTbull/textinator/pull/27)\n- docs: add bwagner as a contributor for test [`#25`](https://github.com/RhetTbull/textinator/pull/25)\n- FIXES: tests on 14.1.1 (Sonoma) Apple M1 Max [`#24`](https://github.com/RhetTbull/textinator/pull/24)\n- Fixes #19 [`#23`](https://github.com/RhetTbull/textinator/pull/23)\n- Merge pull request #23 from RhetTbull/auto_switch_icon_19 [`#19`](https://github.com/RhetTbull/textinator/issues/19)\n- Fixes #19 [`#19`](https://github.com/RhetTbull/textinator/issues/19)\n- Added test for #16 [`55471ab`](https://github.com/RhetTbull/textinator/commit/55471ab2d764bc3e80193257fc5c0f4327b6f93f)\n- Updated build script [`0df7b0e`](https://github.com/RhetTbull/textinator/commit/0df7b0e1ce2166f900d5f70bbe215e0097662aa2)\n- Updated dependencies for python 3.11 [`8ea99e8`](https://github.com/RhetTbull/textinator/commit/8ea99e81fd0e14938563b8ce2c7fd72f47ca54ab)\n- Updated README developer notes, #22 [`22d6bda`](https://github.com/RhetTbull/textinator/commit/22d6bda87f5a9f37a0ef2d54b8c6bd64de208542)\n- Updated dependencies, #21 [`502cb6b`](https://github.com/RhetTbull/textinator/commit/502cb6b6f372f3e1a83358c3c84b4801da085a68)\n\n#### [v0.9.1](https://github.com/RhetTbull/textinator/compare/v0.9.0...v0.9.1)\n\n> 25 October 2022\n\n- Add tests [`#15`](https://github.com/RhetTbull/textinator/pull/15)\n- Added Services menu action for detecting text in image files #5 [`#13`](https://github.com/RhetTbull/textinator/pull/13)\n- Added initial tests [`86a562c`](https://github.com/RhetTbull/textinator/commit/86a562c46bf2bead5cb621999c3cdfa536c36483)\n- Added tests [`327908d`](https://github.com/RhetTbull/textinator/commit/327908deaa9696404280b652a432c5b60a51ee10)\n- Improved comments, some refactoring [`c7a18be`](https://github.com/RhetTbull/textinator/commit/c7a18bed9c6754ee69c0092b8064b0a19945ef49)\n- Updated comments [`b11e7b1`](https://github.com/RhetTbull/textinator/commit/b11e7b1c5744a4d6b8ff58f6afd9fa63547cce18)\n- Updated docs with developer notes [`299cfbf`](https://github.com/RhetTbull/textinator/commit/299cfbfd8a7a440e637c8ccda2458503644f961b)\n\n#### [v0.9.0](https://github.com/RhetTbull/textinator/compare/v0.8.2...v0.9.0)\n\n> 21 October 2022\n\n- Feature screenshot in clipboard 004 [`#12`](https://github.com/RhetTbull/textinator/pull/12)\n- Refactored textinator to separate src folder [`29492bd`](https://github.com/RhetTbull/textinator/commit/29492bd6214aa3d1fb96b809cb486e8bf46d9e00)\n- Implemented #4, clipboard detection [`82ffe10`](https://github.com/RhetTbull/textinator/commit/82ffe10bc074ea7cdf8f105fda697b506b893558)\n- Updated dependencies, #11 [`972565d`](https://github.com/RhetTbull/textinator/commit/972565daf6278f05b56f0f3cbab141cbc1b22295)\n- Refactored source code [`e235d72`](https://github.com/RhetTbull/textinator/commit/e235d72946f30a76abc4f1c5595608b916a797e0)\n- Version bump [`f1dbcd5`](https://github.com/RhetTbull/textinator/commit/f1dbcd5772c17fa6c3bbbcd0fba67441864d05fb)\n\n#### [v0.8.2](https://github.com/RhetTbull/textinator/compare/v0.8.1...v0.8.2)\n\n> 18 October 2022\n\n- Bug fix for pause/resume [`eebae51`](https://github.com/RhetTbull/textinator/commit/eebae51fd70f1faf41efe3d080f6652735fcef2e)\n- Bumped version [`110ffee`](https://github.com/RhetTbull/textinator/commit/110ffee612f96aa7d72c6ba879feadab88de98b5)\n\n#### [v0.8.1](https://github.com/RhetTbull/textinator/compare/v0.8.0...v0.8.1)\n\n> 18 October 2022\n\n- docs: add bwagner as a contributor for code [`#9`](https://github.com/RhetTbull/textinator/pull/9)\n- FIXES: typo in function call .initWithCompletionHandler_ [`#8`](https://github.com/RhetTbull/textinator/pull/8)\n- docs: add bwagner as a contributor for ideas [`#7`](https://github.com/RhetTbull/textinator/pull/7)\n- docs: create .all-contributorsrc [skip ci] [`725b6c9`](https://github.com/RhetTbull/textinator/commit/725b6c92017297b0c22366a85afd09bb1de1f5ca)\n- docs: update README.md [skip ci] [`ec70e57`](https://github.com/RhetTbull/textinator/commit/ec70e57314b1df4a4a4d9ae7443719e9874fb5cf)\n- Added pause/resume, #3 [`4c33e6a`](https://github.com/RhetTbull/textinator/commit/4c33e6ac655e74d5050a24c6c89a8209d9aacd8c)\n- Bumped version [`44bd293`](https://github.com/RhetTbull/textinator/commit/44bd293ff2e5786b239856a99e042a46280f980e)\n- Updated all-contributors badge [`6337c32`](https://github.com/RhetTbull/textinator/commit/6337c3236e30affd78708611b6c9333b418db959)\n\n#### [v0.8.0](https://github.com/RhetTbull/textinator/compare/v0.7.2...v0.8.0)\n\n> 28 September 2022\n\n- Added 'Start Textinator on login' option [`209b317`](https://github.com/RhetTbull/textinator/commit/209b3172683aede28dab76b5b7009df33cff417f)\n- Fixed unnecessary import [`614f362`](https://github.com/RhetTbull/textinator/commit/614f362b570aaa5270348e16d2edd93868cd2b4f)\n- Updated dependencies [`cf18c41`](https://github.com/RhetTbull/textinator/commit/cf18c41bce33136180924b9761a8a65c1f09c446)\n\n#### [v0.7.2](https://github.com/RhetTbull/textinator/compare/v0.7.0...v0.7.2)\n\n> 18 September 2022\n\n- Fixed logging problem [`ae1db74`](https://github.com/RhetTbull/textinator/commit/ae1db7412701218288525346139e6dacdb3526c4)\n\n#### [v0.7.0](https://github.com/RhetTbull/textinator/compare/0.4.0...v0.7.0)\n\n> 18 September 2022\n\n- Request Desktop access if needed [`fe70412`](https://github.com/RhetTbull/textinator/commit/fe70412f073195cbffd163a2733af3dff9e4a14c)\n- Added QR code scanning [`1096439`](https://github.com/RhetTbull/textinator/commit/10964399032a1237917c97bc2e97a38c1947f9e5)\n- Updated logging [`fc6a53b`](https://github.com/RhetTbull/textinator/commit/fc6a53b6bbee197f85a0aa2942bbf8646439b987)\n\n#### 0.4.0\n\n> 21 June 2022\n\n- First commit [`b8f5671`](https://github.com/RhetTbull/textinator/commit/b8f567110016a4e51764f4f3a8d34aecb80c732c)\n- Feature complete [`b738d4c`](https://github.com/RhetTbull/textinator/commit/b738d4c65fa648f72f7474ba62ef9187f097af32)\n- Added language selection, #2 [`01365e4`](https://github.com/RhetTbull/textinator/commit/01365e4224a2beaf28a262e2ba868184ba481b72)\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019-2021 Rhet Turnbull\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Textinator\n<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->\n[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat)](#contributors)\n<!-- ALL-CONTRIBUTORS-BADGE:END -->\n\nSimple macOS StatusBar / menu bar app to perform automatic text detection on screenshots.\n\n## Overview\n\nInstall 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.\n\n[![Watch the screencast](https://img.youtube.com/vi/K_3MXOeBBdY/maxresdefault.jpg)](https://youtu.be/K_3MXOeBBdY)\n\n## Installation\n\nDownload 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.\n\nTo 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.\n\n![Installer DMG](images/installer.png)\n\nAlternatively, to build from source:\n\n- clone the repo\n- cd into the repo directory\n- create a virtual environment and activate it\n- python3 -m pip install -r requirements.txt\n- python3 setup.py py2app\n- Copy dist/textinator.app to /Applications\n- Follow instructions below to grant Desktop and optionally Full Disk Access\n\nSee also [Developer Notes](#developer-notes) below.\n\nGrant Desktop access:\n\nTextinator 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.\n\n![Desktop access](images/textinator_desktop_access.png)\n\nThe 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.\n\nGrant Full Disk Access:\n\n- Open System Settings...>Privacy & Security> Full Disk Access\n- Click the padlock if locked to unlock it and add Textinator to the list of allowed apps\n\n![System Preferences > Security & Privacy](images/Full_Disk_Access.png)\n\n## Upgrading\n\nTo 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.)\n\n## Usage\n\n- Launch Textinator from the Applications folder\n- Grant Desktop access if prompted\n- Click the menu bar icon to see preferences\n\n![Menu Bar Icon](images/textinator_settings.png)\n\n- Press ⌘ + ⇧ + 4 (`Cmd + Shift + 4`) to take a screenshot then paste the detected text wherever you'd like it to be.\n\n- 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.\n\n- 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.\n\n## Settings\n\n- `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`).\n- `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).\n- `Always detect English`: If checked, always attempts to detect English text in addition to the primary language selected by `Text recognition language` setting.\n- `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.\n- `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`.\n- `Detect QR Codes`: In addition to detecting text, also detect QR codes and copy the decoded payload text to the clipboard.\n- `Notification`: Whether or not to show a notification when text is detected.\n- `Keep linebreaks`: Whether or not to keep linebreaks in the detected text; if not set, linebreaks will be stripped.\n- `Append to clipboard`: Append to the clipboard instead of overwriting it.\n- `Clear clipboard`: Clear the clipboard.\n- `Confirm clipboard changes`: Show a confirmation dialog with detected text before copying to the clipboard.\n- `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).\n- `About Textinator`: Show the about dialog.\n- `Quit Textinator`: Quit Textinator.\n\nWhen 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.\n\n![System Events permission](images/system_events_access.png)\n\n## Inspiration\n\nI 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!\n\nThis 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.\n\nTextinator 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!\n\n## How Textinator Works\n\nTextinator 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.\n\nAt 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.\n\nWhen 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.\n\nTextinator can also monitor the clipboard and detect text in images copied to the clipboard.\n\n## Notes\n\n- If building with [pyenv](https://github.com/pyenv/pyenv) installed python, you'll need to build the python with framework support:\n  - `env PYTHON_CONFIGURE_OPTS=\"--enable-framework\" pyenv install -v 3.9.11`\n- 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.\n\n## License\n\nMIT License\n\n## See Also\n\n[Text Sniper](https://textsniper.app/) which inspired this project.\n\n## Contributors ✨\n\nThanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bwagner\"><img src=\"https://avatars.githubusercontent.com/u/447049?v=4?s=75\" width=\"75px;\" alt=\"Bernhard Wagner\"/><br /><sub><b>Bernhard Wagner</b></sub></a><br /><a href=\"#ideas-bwagner\" title=\"Ideas, Planning, & Feedback\">🤔</a> <a href=\"https://github.com/RhetTbull/textinator/commits?author=bwagner\" title=\"Code\">💻</a> <a href=\"https://github.com/RhetTbull/textinator/commits?author=bwagner\" title=\"Tests\">⚠️</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nThis project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!\n\n## Developer Notes\n\nIf you want to work on Textinator yourself or contribute changes, here are some notes:\n\nClone the repo and cd into the repo directory.\n\n`git clone git@github.com:RhetTbull/textinator.git`\n`cd textinator`\n\nIf you want to contribute back to Textinator, fork the repo and clone your fork instead.\n\nInstall requirements and development requirements via pip:\n\n```console\npython3 -m pip install -r requirements.txt -r dev_requirements.txt\npre-commit install\n```\n\nSee also notes below about [Testing](#testing).\n\nBuilding the DMG for distribution requires [create-dmg](https://github.com/create-dmg/create-dmg) which can be installed with [homebrew](https://brew.sh/):\n\n`brew install create-dmg`\n\nTo build Textinator, run the `build.sh` script:\n\n`./build.sh`\n\nThis script cleans out old build files, builds the app with [py2app](https://py2app.readthedocs.io/en/latest/), signs the app, and builds the DMG.\n\nTextinator 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.\n\nThe preferences can be read from the command line with:\n\n`defaults read ~/Library/Application\\ Support/Textinator/Textinator.plist`\n\nFor 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:\n\n`defaults write ~/Library/Application\\ Support/Textinator/Textinator.plist debug -bool true`\n\nSimilarly, you can disable the debug log with:\n\n`defaults write ~/Library/Application\\ Support/Textinator/Textinator.plist debug -bool false`\n\nWhen `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`.\n\nMost 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`.\n\nThe 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.\n\nI'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.\n\n## Testing\n\nTextinator 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`.\n\nThe 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`.\n\nThe 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.\n\nThe 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.\n\nThe Services menu item is not tested by the test suite so this feature should be tested manually.\n"
  },
  {
    "path": "build.sh",
    "content": "#!/bin/sh\n\n# Build, sign and package Textinator as a DMG file for release\n# this requires create-dmg: `brew install create-dmg` to install\n\n# build with py2app\necho \"Cleaning up old build files...\"\ntest -d dist && rm -rf dist/\ntest -d build && rm -rf build/\n\necho \"Running py2app\"\npython3 setup.py py2app\n\n# TODO: this doesn't appear to be needed (only for sandboxed apps)\n# py2app will sign the app with the ad-hoc certificate\n# sign with ad-hoc certificate (if you have an Apple Developer ID, you can use your developer certificate instead)\n# for the app to send AppleEvents to other apps, it needs to be signed and include the\n# com.apple.security.automation.apple-events entitlement in the entitlements file\n# --force: force signing even if the app is already signed\n# --deep: recursively sign all embedded frameworks and plugins\n# --options=runtime: Preserve the hardened runtime version\n# --entitlements: use specified the entitlements file\n# -s -: sign the code at the path(s) given using this identity; \"-\" means use the ad-hoc certificate\n# echo \"Signing with codesign\"\n# codesign \\\n#   --force \\\n#   --deep \\\n#   --options=runtime \\\n#   --preserve-metadata=identifier,entitlements,flags,runtime \\\n#   --entitlements=entitlements.plist \\\n#   -s - \\\n#   dist/Textinator.app\n\n# create installer DMG\n# to add a background image to the DMG, add the following to the create-dmg command:\n#   --background \"installer_background.png\" \\\necho \"Creating DMG\"\ntest -f Textinator-Installer.dmg && rm Textinator-Installer.dmg\ncreate-dmg \\\n  --volname \"Textinator Installer\" \\\n  --volicon \"icon.icns\" \\\n  --window-pos 200 120 \\\n  --window-size 800 400 \\\n  --icon-size 100 \\\n  --icon \"Textinator.app\" 200 190 \\\n  --hide-extension \"Textinator.app\" \\\n  --app-drop-link 600 185 \\\n  \"Textinator-Installer.dmg\" \\\n  \"dist/\"\n"
  },
  {
    "path": "dev_requirements.txt",
    "content": "bump2version>=1.0.1,<2.0.0\nosxmetadata>=1.0.0,<2.0.0\npytest>=7.1.3,<8.0.0\npre-commit\n"
  },
  {
    "path": "entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.automation.apple-events</key>\n\t<true/>\n</dict>\n</plist>"
  },
  {
    "path": "requirements.txt",
    "content": "py-applescript==1.0.3 \npy2app>=0.28.6\npyobjc-core>=9.2\npyobjc-framework-cocoa>=9.2\npyobjc-framework-coreml>=9.2\npyobjc-framework-quartz>=9.2\npyobjc-framework-vision>=9.2\nrumps>=0.4.0,<0.5.0\nwheel>=0.41.2\n"
  },
  {
    "path": "setup.py",
    "content": "\"\"\"\nThis is a setup.py script generated by py2applet\n\nUsage:\n    python setup.py py2app\n\"\"\"\n\nfrom setuptools import setup\n\n# The version number; do not change this manually! It is updated by bumpversion (https://github.com/c4urself/bump2version)\n__version__ = \"0.10.1\"\n\n# The file that contains the main application\nAPP = [\"src/textinator.py\"]\n\n# Include additional python modules here; probably not the best way to do this\n# but I couldn't figure out how else to get py2app to include modules in the src/ folder\nDATA_FILES = [\n    \"src/appkitgui.py\",\n    \"src/confirmation_window.py\",\n    \"src/icon.png\",\n    \"src/icon_paused.png\",\n    \"src/loginitems.py\",\n    \"src/macvision.py\",\n    \"src/pasteboard.py\",\n    \"src/utils.py\",\n]\n\n# These values will be included by py2app into the Info.plist file in the App bundle\n# See https://developer.apple.com/documentation/bundleresources/information_property_list?language=objc\n# for more information\nPLIST = {\n    # LSUIElement tells the OS that this app is a background app that doesn't appear in the Dock\n    \"LSUIElement\": True,\n    # CFBundleShortVersionString is the version number that appears in the App's About box\n    \"CFBundleShortVersionString\": __version__,\n    # CFBundleVersion is the build version (here we use the same value as the short version)\n    \"CFBundleVersion\": __version__,\n    # NSDesktopFolderUsageDescription is the message that appears when the app asks for permission to access the Desktop folder\n    # Likewise for NSDocumentsFolderUsageDescription and NSDownloadsFolderUsageDescription\n    \"NSDesktopFolderUsageDescription\": \"Textinator needs access to your Desktop folder to detect new screenshots. \"\n    \"If you have changed the default location for screenshots, \"\n    \"you will also need to grant Textinator full disk access in \"\n    \"System Preferences > Security & Privacy > Privacy > Full Disk Access.\",\n    \"NSDocumentsFolderUsageDescription\": \"Textinator needs access to your Documents folder to detect new screenshots. \",\n    \"NSDownloadsFolderUsageDescription\": \"Textinator needs access to your Downloads folder to detect new screenshots. \",\n    # NSAppleEventsUsageDescription is the message that appears when the app asks for permission to send Apple events\n    \"NSAppleEventsUsageDescription\": \"Textinator needs permission to send AppleScript events to add itself to Login Items.\",\n    # NSServices is a list of services that the app provides that will appear in the Services menu\n    # For more information on NSServices, see: https://developer.apple.com/documentation/bundleresources/information_property_list/nsservices?language=objc\n    \"NSServices\": [\n        {\n            \"NSMenuItem\": {\"default\": \"Detect Text With Textinator\"},\n            \"NSMessage\": \"detectTextInImage\",\n            \"NSPortName\": \"Textinator\",\n            \"NSUserData\": \"detectTextInImage\",\n            \"NSRequiredContext\": {\"NSTextContent\": \"FilePath\"},\n            \"NSSendTypes\": [\"NSPasteboardTypeURL\"],\n            \"NSSendFileTypes\": [\"public.image\"],\n        },\n    ],\n}\n\n# Options for py2app\nOPTIONS = {\n    # The icon file to use for the app (this is App icon in Finder, not the status bar icon)\n    \"iconfile\": \"icon.icns\",\n    \"plist\": PLIST,\n}\n\nsetup(\n    app=APP,\n    data_files=DATA_FILES,\n    name=\"Textinator\",\n    options={\"py2app\": OPTIONS},\n    setup_requires=[\"py2app\"],\n)\n"
  },
  {
    "path": "src/README.md",
    "content": "# Source files for Textinator\n\nThe 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.\n\n`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.\n"
  },
  {
    "path": "src/appkitgui.py",
    "content": "\"\"\"Toolkit to help create a native macOS GUI with AppKit\n\nCopyright (c) 2023, Rhet Turnbull; licensed under MIT License.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport datetime\nimport os\nimport zoneinfo\nfrom collections.abc import Iterable\nfrom dataclasses import dataclass\nfrom typing import Any, Callable\n\nimport AppKit\nfrom AppKit import (\n    NSApp,\n    NSBox,\n    NSButton,\n    NSComboBox,\n    NSDatePicker,\n    NSImageView,\n    NSScrollView,\n    NSStackView,\n    NSTextField,\n    NSTextView,\n    NSTimeZone,\n    NSView,\n)\nfrom Foundation import NSURL, NSDate, NSLog, NSMakeRect, NSMakeSize, NSObject\nfrom objc import objc_method, python_method, super\n\n################################################################################\n# Constants\n################################################################################\n\n# margin between window edge and content\nEDGE_INSET = 20\n\n# padding between elements\nPADDING = 8\n\n\n################################################################################\n# Window and Application\n################################################################################\n\n\ndef window(\n    title: str | None = None,\n    size: tuple[int, int] = (600, 600),\n    mask: int = AppKit.NSWindowStyleMaskTitled\n    | AppKit.NSWindowStyleMaskClosable\n    | AppKit.NSWindowStyleMaskResizable,\n) -> AppKit.NSWindow:\n    \"\"\"Create a window with a title and size\"\"\"\n    new_window = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(\n        NSMakeRect(0, 0, *size),\n        mask,\n        AppKit.NSBackingStoreBuffered,\n        False,\n    )\n    new_window.center()\n    if title is not None:\n        new_window.setTitle_(title)\n    return new_window\n\n\ndef main_view(\n    window: AppKit.NSWindow,\n    align: int = AppKit.NSLayoutAttributeLeft,\n    padding: int = PADDING,\n    edge_inset: tuple[float, float, float, float] | float = EDGE_INSET,\n) -> AppKit.NSView:\n    \"\"\"Create a main NSStackView for the window which contains all other views\n\n    Args:\n        window: the NSWindow to attach the view to\n        align: NSLayoutAttribute alignment constant\n        padding: padding between elements\n        edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets)\n    \"\"\"\n\n    # This uses appkitgui.StackView which is a subclass of NSStackView\n    # that supports some list methods such as append, extend, remove, ...\n    main_view = StackView.stackViewWithViews_(None)\n    main_view.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationVertical)\n    main_view.setSpacing_(padding)\n    if isinstance(edge_inset, (int, float)):\n        # use even insets\n        edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset)\n    else:\n        edge_insets = edge_inset\n    main_view.setEdgeInsets_(edge_insets)\n    main_view.setDistribution_(AppKit.NSStackViewDistributionFill)\n    main_view.setAlignment_(align)\n\n    window.contentView().addSubview_(main_view)\n    top_constraint = main_view.topAnchor().constraintEqualToAnchor_(\n        main_view.superview().topAnchor()\n    )\n    top_constraint.setActive_(True)\n    bottom_constraint = main_view.bottomAnchor().constraintEqualToAnchor_(\n        main_view.superview().bottomAnchor()\n    )\n    bottom_constraint.setActive_(True)\n    left_constraint = main_view.leftAnchor().constraintEqualToAnchor_(\n        main_view.superview().leftAnchor()\n    )\n    left_constraint.setActive_(True)\n    right_constraint = main_view.rightAnchor().constraintEqualToAnchor_(\n        main_view.superview().rightAnchor()\n    )\n    right_constraint.setActive_(True)\n\n    return main_view\n\n\n################################################################################\n# Custom views and control classes\n################################################################################\n\n\nclass StackView(NSStackView):\n    \"\"\"NSStackView that supports list methods for adding child views\"\"\"\n\n    @python_method\n    def append(self, view: NSView):\n        \"\"\"Add view to stack\"\"\"\n        self.addArrangedSubview_(view)\n\n    @python_method\n    def extend(self, views: Iterable[NSView]):\n        \"\"\"Extend stack with the contents of views\"\"\"\n        for view in views:\n            self.append(view)\n\n    @python_method\n    def insert(self, i: int, view: NSView):\n        \"\"\"Insert view at index i\"\"\"\n        self.insertArrangedSubview_atIndex_(view, i)\n\n    @python_method\n    def remove(self, view: NSView):\n        \"\"\"Remove view from the stack\"\"\"\n        self.removeArrangedSubview_(view)\n\n\nclass ScrolledStackView(NSScrollView):\n    \"\"\"A scrollable stack view; use self.documentView() or self.stack to access the stack view\"\"\"\n\n    def initWithStack_(\n        self,\n        stack: NSStackView | StackView,\n        vscroll: bool = False,\n        hscroll: bool = False,\n    ):\n        self = super().init()\n        if not self:\n            return\n\n        self.stack: NSStackView | StackView = stack\n        self.setHasVerticalScroller_(vscroll)\n        self.setHasHorizontalScroller_(hscroll)\n        self.setBorderType_(AppKit.NSNoBorder)\n        self.setTranslatesAutoresizingMaskIntoConstraints_(False)\n        self.setDrawsBackground_(False)\n        self.setAutohidesScrollers_(True)\n\n        self.setDocumentView_(self.stack)\n\n        return self\n\n    @python_method\n    def append(self, view: NSView):\n        \"\"\"Add view to stack\"\"\"\n        self.documentView().addArrangedSubview_(view)\n\n    @python_method\n    def extend(self, views: Iterable[NSView]):\n        \"\"\"Extend stack with the contents of views\"\"\"\n        for view in views:\n            self.documentView().append(view)\n\n    @python_method\n    def insert(self, i: int, view: NSView):\n        \"\"\"Insert view at index i\"\"\"\n        self.documentView().insertArrangedSubview_atIndex_(view, i)\n\n    @python_method\n    def remove(self, view: NSView):\n        \"\"\"Remove view from the stack\"\"\"\n        self.documentView().removeArrangedSubview_(view)\n\n    def setSpacing_(self, spacing):\n        self.stack.setSpacing_(spacing)\n\n    def setOrientation_(self, orientation):\n        self.stack.setOrientation_(orientation)\n\n    def setDistribution_(self, distribution):\n        self.stack.setDistribution_(distribution)\n\n    def setAlignment_(self, alignment):\n        self.stack.setAlignment_(alignment)\n\n    def setEdgeInsets_(self, edge_inset):\n        self.stack.setEdgeInsets_(edge_inset)\n\n\nclass LinkLabel(NSTextField):\n    \"\"\"Uneditable text field that displays a clickable link\"\"\"\n\n    def initWithText_URL_(self, text: str, url: str):\n        self = super().init()\n\n        if not self:\n            return\n\n        attr_str = self.attributedStringWithLinkToURL_text_(url, text)\n        self.setAttributedStringValue_(attr_str)\n        self.url = NSURL.URLWithString_(url)\n        self.setBordered_(False)\n        self.setSelectable_(False)\n        self.setEditable_(False)\n        self.setBezeled_(False)\n        self.setDrawsBackground_(False)\n\n        return self\n\n    def resetCursorRects(self):\n        self.addCursorRect_cursor_(self.bounds(), AppKit.NSCursor.pointingHandCursor())\n\n    def mouseDown_(self, event):\n        AppKit.NSWorkspace.sharedWorkspace().openURL_(self.url)\n\n    def mouseEntered_(self, event):\n        AppKit.NSCursor.pointingHandCursor().push()\n\n    def mouseExited_(self, event):\n        AppKit.NSCursor.pop()\n\n    def attributedStringWithLinkToURL_text_(self, url: str, text: str):\n        linkAttributes = {\n            AppKit.NSLinkAttributeName: NSURL.URLWithString_(url),\n            AppKit.NSUnderlineStyleAttributeName: AppKit.NSUnderlineStyleSingle,\n            AppKit.NSForegroundColorAttributeName: AppKit.NSColor.linkColor(),\n            # AppKit.NSCursorAttributeName: AppKit.NSCursor.pointingHandCursor(),\n        }\n        return AppKit.NSAttributedString.alloc().initWithString_attributes_(\n            text, linkAttributes\n        )\n\n\nclass ComboBoxDelegate(NSObject):\n    \"\"\"Helper class to handle combo box events\"\"\"\n\n    def initWithTarget_Action_(self, target: NSObject, action: Callable | str | None):\n        self = super().init()\n        if not self:\n            return\n\n        self.target = target\n        self.action_change = action\n        return self\n\n    @objc_method\n    def comboBoxSelectionDidChange_(self, notification):\n        if self.action_change:\n            if type(self.action_change) == str:\n                self.target.performSelector_withObject_(\n                    self.action_change, notification.object()\n                )\n            else:\n                self.action_change(notification.object())\n\n\nclass ComboBox(NSComboBox):\n    \"\"\"NSComboBox that stores a reference to its delegate\n\n    Note:\n        This is required to maintain a reference to the delegate, otherwise it will\n        not be retained after the ComboBox is created.\n    \"\"\"\n\n    def setDelegate_(self, delegate: NSObject | None):\n        self.delegate = delegate\n        if delegate is not None:\n            super().setDelegate_(delegate)\n\n\nclass ScrollViewWithTextView(NSScrollView):\n    def initWithSize_VScroll_(self, size: tuple[float, float], vscroll: bool):\n        self = super().initWithFrame_(NSMakeRect(0, 0, *size))\n        if not self:\n            return\n        self.setBorderType_(AppKit.NSBezelBorder)\n        self.setHasVerticalScroller_(vscroll)\n        self.setDrawsBackground_(True)\n        self.setAutohidesScrollers_(True)\n        self.setAutoresizingMask_(\n            AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable\n        )\n        self.setTranslatesAutoresizingMaskIntoConstraints_(False)\n\n        width_constraint = self.widthAnchor().constraintEqualToConstant_(size[0])\n        width_constraint.setActive_(True)\n        height_constraint = self.heightAnchor().constraintEqualToConstant_(size[1])\n        height_constraint.setActive_(True)\n\n        contentSize = self.contentSize()\n        self.textView = NSTextView.alloc().initWithFrame_(self.contentView().frame())\n        self.textView.setMinSize_(NSMakeSize(0.0, contentSize.height))\n        self.textView.setMaxSize_(NSMakeSize(float(\"inf\"), float(\"inf\")))\n        self.textView.setVerticallyResizable_(True)\n        self.textView.setHorizontallyResizable_(False)\n        self.setDocumentView_(self.textView)\n\n        return self\n\n    # provide access to some of the text view's methods\n    def string(self):\n        return self.textView.string()\n\n    def setString_(self, text: str):\n        self.textView.setString_(text)\n\n    def setEditable_(self, editable: bool):\n        self.textView.setEditable_(editable)\n\n    def setSelectable_(self, selectable: bool):\n        self.textView.setSelectable_(selectable)\n\n    def setFont_(self, font: AppKit.NSFont):\n        self.textView.setFont_(font)\n\n    def setTextColor_(self, color: AppKit.NSColor):\n        self.textView.setTextColor_(color)\n\n    def setBackgroundColor_(self, color: AppKit.NSColor):\n        self.textView.setBackgroundColor_(color)\n\n\n################################################################################\n# Helper functions to create views and controls\n################################################################################\n\n\ndef hstack(\n    align: int = AppKit.NSLayoutAttributeTop,\n    distribute: int | None = AppKit.NSStackViewDistributionFill,\n    vscroll: bool = False,\n    hscroll: bool = False,\n    views: (\n        Iterable[AppKit.NSView] | AppKit.NSArray | AppKit.NSMutableArray | None\n    ) = None,\n    edge_inset: tuple[float, float, float, float] | float = 0,\n) -> StackView:\n    \"\"\"Create a horizontal StackView\n\n    Args:\n        align:NSLayoutAttribute alignment constant\n        distribute: NSStackViewDistribution distrubution constant\n        vscroll: True to add vertical scrollbar\n        hscroll: True to add horizontal scrollbar\n        views: iterable of NSViews to add to the stack\n        edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets)\n\n    Returns: StackView\n    \"\"\"\n    hstack = StackView.stackViewWithViews_(views)\n    hstack.setSpacing_(PADDING)\n    hstack.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationHorizontal)\n    if distribute is not None:\n        hstack.setDistribution_(distribute)\n    hstack.setAlignment_(align)\n    hstack.setTranslatesAutoresizingMaskIntoConstraints_(False)\n    hstack.setHuggingPriority_forOrientation_(\n        AppKit.NSLayoutPriorityDefaultHigh,\n        AppKit.NSLayoutConstraintOrientationHorizontal,\n    )\n    if edge_inset:\n        if isinstance(edge_inset, (int, float)):\n            # use even insets\n            edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset)\n        else:\n            edge_insets = edge_inset\n        hstack.setEdgeInsets_(edge_insets)\n    if vscroll or hscroll:\n        scroll_view = ScrolledStackView.alloc().initWithStack_(hstack, vscroll, hscroll)\n        return scroll_view\n    return hstack\n\n\ndef vstack(\n    align: int = AppKit.NSLayoutAttributeLeft,\n    distribute: int | None = None,\n    vscroll: bool = False,\n    hscroll: bool = False,\n    views: AppKit.NSArray | AppKit.NSMutableArray | None = None,\n    edge_inset: tuple[float, float, float, float] | float = 0,\n) -> StackView | ScrolledStackView:\n    \"\"\"Create a vertical StackView\n\n    Args:\n        align:NSLayoutAttribute alignment constant\n        distribute: NSStackViewDistribution distrubution constant\n        vscroll: True to add vertical scrollbar\n        hscroll: True to add horizontal scrollbar\n        views: iterable of NSViews to add to the stack\n        edge_inset: The geometric padding, in points, inside the stack view, surrounding its views (NSEdgeInsets)\n\n    Returns: StackView\n    \"\"\"\n    vstack = StackView.stackViewWithViews_(views)\n    vstack.setSpacing_(PADDING)\n    vstack.setOrientation_(AppKit.NSUserInterfaceLayoutOrientationVertical)\n    if distribute is not None:\n        vstack.setDistribution_(distribute)\n    vstack.setAlignment_(align)\n    vstack.setTranslatesAutoresizingMaskIntoConstraints_(False)\n    # TODO: set priority as arg? or let user set it later?\n    vstack.setHuggingPriority_forOrientation_(\n        AppKit.NSLayoutPriorityDefaultHigh,\n        AppKit.NSLayoutConstraintOrientationVertical,\n    )\n    if edge_inset:\n        if isinstance(edge_inset, (int, float)):\n            # use even insets\n            edge_insets = (edge_inset, edge_inset, edge_inset, edge_inset)\n        else:\n            edge_insets = edge_inset\n        vstack.setEdgeInsets_(edge_insets)\n\n    if vscroll or hscroll:\n        scroll_view = ScrolledStackView.alloc().initWithStack_(vstack, vscroll, hscroll)\n        return scroll_view\n    return vstack\n\n\ndef hspacer() -> NSStackView:\n    \"\"\"Create a horizontal spacer\"\"\"\n    return vstack()\n\n\ndef label(value: str) -> NSTextField:\n    \"\"\"Create a label\"\"\"\n    label = NSTextField.labelWithString_(value)\n    label.setEditable_(False)\n    label.setBordered_(False)\n    label.setBackgroundColor_(AppKit.NSColor.clearColor())\n    return label\n\n\ndef link(text: str, url: str) -> NSTextField:\n    \"\"\"Create a clickable link label\"\"\"\n    return LinkLabel.alloc().initWithText_URL_(text, url)\n\n\ndef button(title: str, target: NSObject, action: Callable | str | None) -> NSButton:\n    \"\"\"Create a button\"\"\"\n    button = NSButton.buttonWithTitle_target_action_(title, target, action)\n    button.setTranslatesAutoresizingMaskIntoConstraints_(False)\n\n    # set hugging priority and compression resistance to prevent button from resizing\n    set_hugging_priority(button)\n    set_compression_resistance(button)\n\n    return button\n\n\ndef checkbox(title: str, target: NSObject, action: Callable | str | None) -> NSButton:\n    \"\"\"Create a checkbox button\"\"\"\n    checkbox = NSButton.buttonWithTitle_target_action_(title, target, action)\n    checkbox.setButtonType_(AppKit.NSButtonTypeSwitch)  # Switch button type\n    return checkbox\n\n\ndef radio_button(\n    title: str, target: NSObject, action: Callable | str | None\n) -> NSButton:\n    \"\"\"Create a radio button\"\"\"\n    radio_button = NSButton.buttonWithTitle_target_action_(title, target, action)\n    radio_button.setButtonType_(AppKit.NSRadioButton)\n    return radio_button\n\n\ndef combo_box(\n    values: list[str] | None,\n    target: NSObject,\n    editable: bool = False,\n    action_return: Callable | str | None = None,\n    action_change: Callable | str | None = None,\n    delegate: NSObject | None = None,\n    width: float | None = None,\n) -> NSComboBox:\n    \"\"\"Create a combo box\n\n    Args:\n        values: list of values to populate the combo box with\n        target: target to send action to\n        editable: whether the combo box is editable\n        action_return: action to send when return is pressed (only called if editable is True)\n        action_change: action to send when the selection is changed\n        delegate: delegate to handle events; if not provided a default delegate is automatically created\n        width: width of the combo box; if None, the combo box will resize to the contents\n\n\n    Note:\n        In order to handle certain events such as return being pressed, a delegate is\n        required. If a delegate is not provided, a default delegate is automatically\n        created which will call the action_return callback when return is pressed.\n        If a delegate is provided, it may implement the following methods:\n\n                - comboBoxSelectionDidChange\n                - comboBox_textView_doCommandBySelector\n    \"\"\"\n\n    combo_box = ComboBox.alloc().initWithFrame_(NSMakeRect(0, 0, 100, 25))\n    combo_box.setTarget_(target)\n    delegate = delegate or ComboBoxDelegate.alloc().initWithTarget_Action_(\n        target, action_change\n    )\n    combo_box.setDelegate_(delegate)\n    if values:\n        combo_box.addItemsWithObjectValues_(values)\n        combo_box.selectItemAtIndex_(0)\n    if action_return:\n        combo_box.setAction_(action_return)\n    combo_box.setCompletes_(True)\n    combo_box.setEditable_(editable)\n\n    if width is not None:\n        constrain_to_width(combo_box, width)\n    return combo_box\n\n\ndef hseparator() -> NSBox:\n    \"\"\"Create a horizontal separator\"\"\"\n    separator = NSBox.alloc().init()\n    separator.setBoxType_(AppKit.NSBoxSeparator)\n    separator.setTranslatesAutoresizingMaskIntoConstraints_(False)\n    return separator\n\n\ndef image_view(\n    path: str | os.PathLike,\n    width: int | None = None,\n    height: int | None = None,\n    scale: int = AppKit.NSImageScaleProportionallyUpOrDown,\n    align: int = AppKit.NSImageAlignCenter,\n) -> NSImageView:\n    \"\"\"Create an image view from a an image file.\n\n    Args:\n        path: path to the image file\n        width: width to constrain the image to; if None, the image will not be constrained\n        height: height to constrain the image to; if None, the image will not be constrained\n        scale: scaling mode for the image\n        align: alignment mode for the image\n\n    Returns: NSImageView\n\n    Note: if only one of width or height set, the other will be scaled to maintain aspect ratio.\n    If image is smaller than the specified width or height and scale is set to AppKit.NSImageScaleNone,\n    the image frame will be larger than the image and the image will be aligned according to align.\n    \"\"\"\n    image = AppKit.NSImage.alloc().initByReferencingFile_(str(path))\n    image_view = NSImageView.imageViewWithImage_(image)\n    image_view.setImageScaling_(scale)\n    image_view.setImageAlignment_(align)\n    image_view.setTranslatesAutoresizingMaskIntoConstraints_(False)\n\n    # if width or height set, constrain to that size\n    # if only one of width or height is set, constrain to that size and scale the other to maintain aspect ratio\n    # if this is not done, the NSImageView intrinsic size may be larger than the window and thus disrupt the layout\n\n    if width:\n        image_view.widthAnchor().constraintEqualToConstant_(width).setActive_(True)\n        if not height:\n            aspect_ratio = image.size().width / image.size().height\n            scaled_height = width / aspect_ratio\n            image_view.heightAnchor().constraintEqualToConstant_(\n                scaled_height\n            ).setActive_(True)\n    if height:\n        image_view.heightAnchor().constraintEqualToConstant_(height).setActive_(True)\n        if not width:\n            aspect_ratio = image.size().width / image.size().height\n            scaled_width = height * aspect_ratio\n            image_view.widthAnchor().constraintEqualToConstant_(\n                scaled_width\n            ).setActive_(True)\n\n    return image_view\n\n\ndef date_picker(\n    style: int = AppKit.NSDatePickerStyleClockAndCalendar,\n    elements: int = AppKit.NSDatePickerElementFlagYearMonthDay,\n    mode: int = AppKit.NSDatePickerModeSingle,\n    date: datetime.date | datetime.datetime | None = None,\n    target: NSObject | None = None,\n    action: Callable | str | None = None,\n    size: tuple[int, int] = (200, 50),\n) -> NSDatePicker:\n    \"\"\"Create a date picker\n\n    Args:\n        style: style of the date picker, an AppKit.NSDatePickerStyle\n        elements: elements to display in the date picker, an AppKit.NSDatePickerElementFlag\n        mode: mode of the date picker, an AppKit.NSDatePickerMode\n        date: initial date of the date picker; if None, defaults to the current date\n        target: target to send action to\n        action: action to send when the date is changed\n        size: size of the date picker\n\n    Returns: NSDatePicker\n    \"\"\"\n    date = date or datetime.date.today()\n    date_picker = NSDatePicker.alloc().initWithFrame_(NSMakeRect(0, 0, *size))\n    date_picker.setDatePickerStyle_(style)\n    date_picker.setDatePickerElements_(elements)\n    date_picker.setDatePickerMode_(mode)\n    date_picker.setDateValue_(date)\n    date_picker.setTimeZone_(NSTimeZone.localTimeZone())\n    date_picker.setTranslatesAutoresizingMaskIntoConstraints_(False)\n\n    if target:\n        date_picker.setTarget_(target)\n    if action:\n        date_picker.setAction_(action)\n    return date_picker\n\n\ndef time_picker(\n    style: int = AppKit.NSDatePickerStyleTextFieldAndStepper,\n    elements: int = AppKit.NSDatePickerElementFlagHourMinute,\n    mode: int = AppKit.NSDatePickerModeSingle,\n    time: datetime.datetime | datetime.time | None = None,\n    target: NSObject | None = None,\n    action: Callable | str | None = None,\n) -> NSDatePicker:\n    \"\"\"Create a time picker\n\n    Args:\n        style: style of the date picker, an AppKit.NSDatePickerStyle\n        elements: elements to display in the date picker, an AppKit.NSDatePickerElementFlag\n        mode: mode of the date picker, an AppKit.NSDatePickerMode\n        time: initial time of the date picker; if None, defaults to the current time\n        target: target to send action to\n        action: action to send when the date is changed\n\n    Returns: NSDatePicker\n\n\n    Note: This function is a wrapper around date_picker, with the date picker style set to\n    display a time picker.\n    \"\"\"\n    # if time is only a time, convert to datetime with today's date\n    # as the date picker requires a datetime or date\n    if isinstance(time, datetime.time):\n        time = datetime.datetime.combine(datetime.date.today(), time)\n    time = time or datetime.datetime.now()\n    return date_picker(\n        style=style,\n        elements=elements,\n        mode=mode,\n        date=time,\n        target=target,\n        action=action,\n    )\n\n\ndef text_view(\n    size: tuple[float, float] = (400, 100), vscroll: bool = True\n) -> NSTextView:\n    \"\"\"Create a text view with optional vertical scroll\"\"\"\n    return ScrollViewWithTextView.alloc().initWithSize_VScroll_(size, vscroll)\n\n\ndef text_field(\n    size: tuple[float, float] = (200, 25),\n    placeholder: str | None = None,\n    target: NSObject | None = None,\n    action: Callable | str | None = None,\n) -> NSTextField:\n    \"\"\"Create a text field\"\"\"\n    text_field = NSTextField.alloc().initWithFrame_(NSMakeRect(0, 0, *size))\n    text_field.setBezeled_(True)\n    text_field.setBezelStyle_(AppKit.NSTextFieldSquareBezel)\n    text_field.setTranslatesAutoresizingMaskIntoConstraints_(False)\n    width_constraint = text_field.widthAnchor().constraintEqualToConstant_(size[0])\n    width_constraint.setActive_(True)\n    height_constraint = text_field.heightAnchor().constraintEqualToConstant_(size[1])\n    height_constraint.setActive_(True)\n    if placeholder:\n        text_field.setPlaceholderString_(placeholder)\n    if target:\n        text_field.setTarget_(target)\n    if action:\n        text_field.setAction_(action)\n\n    return text_field\n\n\n################################################################################\n# Menus\n################################################################################\n\n\ndef menu_bar() -> AppKit.NSMenuItem:\n    \"\"\"Create the app's menu bar\"\"\"\n    menu = menu_with_submenu(None)\n    NSApp.setMainMenu_(menu)\n    return menu\n\n\ndef menu_main() -> AppKit.NSMenu:\n    \"\"\"Return app's main menu\"\"\"\n    return NSApp.mainMenu()\n\n\ndef menu_with_submenu(\n    title: str | None = None, parent: AppKit.NSMenu | None = None\n) -> AppKit.NSMenu:\n    \"\"\"Create a menu with a submenu\"\"\"\n    if title:\n        menu = AppKit.NSMenu.alloc().initWithTitle_(title)\n    else:\n        menu = AppKit.NSMenu.alloc().init()\n    sub_menu = menu_item(title)\n    sub_menu.setSubmenu_(menu)\n    if parent:\n        parent.addItem_(sub_menu)\n    return menu\n\n\ndef menu_item(\n    title: str | None,\n    parent: AppKit.NSMenu | None = None,\n    target: NSObject | None = None,\n    action: Callable | str | None = None,\n    key: str | None = None,\n) -> AppKit.NSMenuItem:\n    \"\"\"Create a menu item and optionally add it to a parent menu\"\"\"\n    key = key or \"\"\n    title = title or \"\"\n    item = AppKit.NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(\n        title, action, key\n    )\n    if target:\n        item.setTarget_(target)\n    if parent:\n        parent.addItem_(item)\n    return item\n\n\n@dataclass\nclass MenuItem:\n    title: str\n    target: NSObject | None = None\n    action: Callable | str | None = None\n    key: str | None = None\n\n\ndef menus_from_dict(\n    menus: dict[str, Iterable[MenuItem | dict]],\n    target: NSObject | None = None,\n    parent: AppKit.NSMenu | None = None,\n) -> dict[str, list[AppKit.NSMenu | dict]]:\n    \"\"\"Create menus from a dict\n\n    Args:\n        menus: dict of\n        target: the default target object for menu items (for example, window class's self)\n        parent: the parent menu; if None, uses the app's top-level menu as parent\n\n    Returns:\n        dict of menus and their children\n\n    Note:\n        target may be specified in the target argument and will be used as the default target for all menu items\n        unless the menu item specifies a different target in the MenuItem.target field.\n        .When calling this from your app, leave parent = None to add the menu items to the app's top-level menu\n    \"\"\"\n    top_level_menus = {}\n    parent = parent or menu_main()\n    for title, value in menus.items():\n        top_menu = menu_with_submenu(title, parent)\n        top_level_menus[title] = [top_menu]\n        if isinstance(value, Iterable):\n            for item in value:\n                if isinstance(item, dict):\n                    top_level_menus[title].append(\n                        menus_from_dict(item, target, top_menu)\n                    )\n                else:\n                    child_item = menu_item(\n                        title=item.title,\n                        parent=top_menu,\n                        action=item.action,\n                        target=item.target or target,\n                        key=item.key,\n                    )\n                    top_level_menus[title].append({item.title: child_item})\n    return top_level_menus\n\n\n################################################################################\n# Utility Functions\n################################################################################\n\n\ndef min_with_index(values: list[float]) -> tuple[int, int]:\n    \"\"\"Return the minimum value and index of the minimum value in a list\"\"\"\n    min_value = min(values)\n    min_index = values.index(min_value)\n    return min_value, min_index\n\n\ndef nsdate_to_datetime(nsdate: NSDate):\n    \"\"\"Convert an NSDate to a datetime in the specified timezone\n\n    Args:\n        nsdate: NSDate to convert\n\n    Returns: naive datetime.datetime\n\n    Note: timezone is the identifier of the timezone to convert to, e.g. \"America/New_York\" or \"US/Eastern\"\n    \"\"\"\n    # NSDate's reference date is 2001-01-01 00:00:00 +0000\n    reference_date = datetime.datetime(2001, 1, 1, tzinfo=datetime.timezone.utc)\n    seconds_since_ref = nsdate.timeIntervalSinceReferenceDate()\n    dt = reference_date + datetime.timedelta(seconds=seconds_since_ref)\n    # all NSDates are naive; use local timezone to adjust from UTC to local\n    timezone = NSTimeZone.localTimeZone().name()\n    try:\n        tz = zoneinfo.ZoneInfo(timezone)\n    except zoneinfo.ZoneInfoNotFoundError:\n        raise ValueError(f\"Invalid timezone: {timezone}\")\n\n    dt = dt.astimezone(tz=tz)\n    return dt.replace(tzinfo=None)\n\n\n################################################################################\n# Constraint helper functions\n################################################################################\n\n\ndef set_hugging_priority(\n    view: NSView,\n    priority: float = AppKit.NSLayoutPriorityDefaultHigh,\n    orientation: int = AppKit.NSLayoutConstraintOrientationHorizontal,\n):\n    \"\"\"Set content hugging priority for a view\"\"\"\n    view.setContentHuggingPriority_forOrientation_(\n        priority,\n        orientation,\n    )\n\n\ndef set_compression_resistance(\n    view: NSView,\n    priority: float = AppKit.NSLayoutPriorityDefaultHigh,\n    orientation: int = AppKit.NSLayoutConstraintOrientationHorizontal,\n):\n    \"\"\"Set content compression resistance for a view\"\"\"\n    view.setContentCompressionResistancePriority_forOrientation_(priority, orientation)\n\n\ndef constrain_stacks_side_by_side(\n    *stacks: NSStackView,\n    weights: list[float] | None = None,\n    parent: NSStackView | None = None,\n    padding: int = 0,\n    edge_inset: float = 0,\n):\n    \"\"\"Constrain a list of NSStackViews to be side by side optionally using weighted widths\n\n    Args:\n        *stacks: NSStackViews to constrain\n        weights: optional weights to use for each stack\n        parent: NSStackView to constrain the stacks to; if None, uses stacks[0].superview()\n        padding: padding between stacks\n        edge_inset: padding between stacks and parent\n\n\n    Note:\n        If weights are provided, the stacks will be constrained to be side by side with\n        widths proportional to the weights. For example, if 2 stacks are provided with\n        weights = [1, 2], the first stack will be half the width of the second stack.\n    \"\"\"\n\n    if len(stacks) < 2:\n        raise ValueError(\"Must provide at least two stacks\")\n\n    parent = parent or stacks[0].superview()\n\n    if weights is not None:\n        min_weight, min_index = min_with_index(weights)\n    else:\n        min_weight, min_index = 1.0, 0\n\n    for i, stack in enumerate(stacks):\n        if i == 0:\n            stack.leadingAnchor().constraintEqualToAnchor_constant_(\n                parent.leadingAnchor(), edge_inset\n            ).setActive_(True)\n        else:\n            stack.leadingAnchor().constraintEqualToAnchor_constant_(\n                stacks[i - 1].trailingAnchor(), padding\n            ).setActive_(True)\n        if i == len(stacks) - 1:\n            stack.trailingAnchor().constraintEqualToAnchor_constant_(\n                parent.trailingAnchor(), -edge_inset\n            ).setActive_(True)\n        stack.topAnchor().constraintEqualToAnchor_constant_(\n            parent.topAnchor(), edge_inset\n        ).setActive_(True)\n        stack.bottomAnchor().constraintEqualToAnchor_constant_(\n            parent.bottomAnchor(), -edge_inset\n        ).setActive_(True)\n\n        if not weights:\n            continue\n\n        weight = weights[i] / min_weight\n\n        AppKit.NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(\n            stack,\n            AppKit.NSLayoutAttributeWidth,\n            AppKit.NSLayoutRelationEqual,\n            stacks[min_index],\n            AppKit.NSLayoutAttributeWidth,\n            weight,\n            0.0,\n        ).setActive_(\n            True\n        )\n\n\ndef constrain_stacks_top_to_bottom(\n    *stacks: NSStackView,\n    weights: list[float] | None = None,\n    parent: NSStackView | None = None,\n    padding: int = 0,\n    edge_inset: float = 0,\n):\n    \"\"\"Constrain a list of NSStackViews to be top to bottom optionally using weighted widths\n\n    Args:\n        *stacks: NSStackViews to constrain\n        weights: optional weights to use for each stack\n        parent: NSStackView to constrain the stacks to; if None, uses stacks[0].superview()\n        padding: padding between stacks\n        edge_inset: padding between stacks and parent\n\n\n    Note:\n        If weights are provided, the stacks will be constrained to be top to bottom with\n        widths proportional to the weights. For example, if 2 stacks are provided with\n        weights = [1, 2], the first stack will be half the width of the second stack.\n    \"\"\"\n\n    if len(stacks) < 2:\n        raise ValueError(\"Must provide at least two stacks\")\n\n    parent = parent or stacks[0].superview()\n\n    if weights is not None:\n        min_weight, min_index = min_with_index(weights)\n    else:\n        min_weight, min_index = 1.0, 0\n\n    for i, stack in enumerate(stacks):\n        if i == 0:\n            stack.topAnchor().constraintEqualToAnchor_constant_(\n                parent.topAnchor(), edge_inset\n            ).setActive_(True)\n        else:\n            stack.topAnchor().constraintEqualToAnchor_constant_(\n                stacks[i - 1].bottomAnchor(), padding\n            ).setActive_(True)\n        if i == len(stacks) - 1:\n            stack.bottomAnchor().constraintEqualToAnchor_constant_(\n                parent.bottomAnchor(), -edge_inset\n            ).setActive_(True)\n        stack.leadingAnchor().constraintEqualToAnchor_constant_(\n            parent.leadingAnchor(), edge_inset\n        ).setActive_(True)\n        stack.trailingAnchor().constraintEqualToAnchor_constant_(\n            parent.trailingAnchor(), -edge_inset\n        ).setActive_(True)\n\n        if not weights:\n            continue\n\n        weight = weights[i] / min_weight\n\n        AppKit.NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(\n            stack,\n            AppKit.NSLayoutAttributeHeight,\n            AppKit.NSLayoutRelationEqual,\n            stacks[min_index],\n            AppKit.NSLayoutAttributeHeight,\n            weight,\n            0.0,\n        ).setActive_(\n            True\n        )\n\n\ndef constrain_to_parent_width(\n    view: NSView, parent: NSView | None = None, edge_inset: float = 0\n):\n    \"\"\"Constrain an NSView to the width of its parent\n\n    Args:\n        view: NSView to constrain\n        parent: NSView to constrain the control to; if None, uses view.superview()\n        edge_inset: margin between control and parent\n    \"\"\"\n    parent = parent or view.superview()\n    view.rightAnchor().constraintEqualToAnchor_constant_(\n        parent.rightAnchor(), -edge_inset\n    ).setActive_(True)\n    view.leftAnchor().constraintEqualToAnchor_constant_(\n        parent.leftAnchor(), edge_inset\n    ).setActive_(True)\n\n\ndef constrain_to_width(view: NSView, width: float | None = None):\n    \"\"\"Constrain an NSView to a fixed width\n\n    Args:\n        view: NSView to constrain\n        width: width to constrain to; if None, does not apply a width constraint\n    \"\"\"\n    if width is not None:\n        view.widthAnchor().constraintEqualToConstant_(width).setActive_(True)\n\n\ndef constrain_to_height(view: NSView, height: float | None = None):\n    \"\"\"Constrain an NSView to a fixed height\n\n    Args:\n        view: NSView to constrain\n        height: height to constrain to; if None, does not apply a height constraint\n    \"\"\"\n    if height is not None:\n        view.heightAnchor().constraintEqualToConstant_(height).setActive_(True)\n\n\ndef constrain_center_x_to_parent(view: NSView, parent: NSView | None = None):\n    \"\"\"Constrain an NSView to the center of its parent along the x-axis\n\n    Args:\n        view: NSView to constrain\n        parent: NSView to constrain the control to; if None, uses view.superview()\n    \"\"\"\n    parent = parent or view.superview()\n    view.centerXAnchor().constraintEqualToAnchor_(parent.centerXAnchor()).setActive_(\n        True\n    )\n\n\ndef constrain_center_y_to_parent(view: NSView, parent: NSView | None = None):\n    \"\"\"Constrain an NSView to the center of its parent along the y-axis\n\n    Args:\n        view: NSView to constrain\n        parent: NSView to constrain the control to; if None, uses view.superview()\n    \"\"\"\n    parent = parent or view.superview()\n    view.centerYAnchor().constraintEqualToAnchor_(parent.centerYAnchor()).setActive_(\n        True\n    )\n\n\ndef constrain_trailing_anchor_to_parent(\n    view: NSView, parent: NSView | None = None, edge_inset: float = EDGE_INSET\n):\n    \"\"\"Constrain an NSView's trailing anchor to it's parent\n\n    Args:\n        view: NSView to constrain\n        parent: NSView to constrain the control to; if None, uses view.superview()\n        inset: inset from trailing edge to apply to constraint (inset will be subtracted from trailing edge)\n    \"\"\"\n    parent = parent or view.superview()\n    view.trailingAnchor().constraintEqualToAnchor_constant_(\n        parent.trailingAnchor(), -edge_inset\n    ).setActive_(True)\n"
  },
  {
    "path": "src/confirmation_window.py",
    "content": "\"\"\"Display a window with text detection contents before copying to clipboard\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport AppKit\nimport objc\nfrom AppKit import NSObject, NSWindow\nfrom Foundation import NSLog\nfrom objc import python_method\n\nimport appkitgui as gui\nfrom pasteboard import Pasteboard\n\nif TYPE_CHECKING:\n    from textinator import Textinator\n\n# constants\nEDGE_INSET = 20\nEDGE_INSETS = (EDGE_INSET, EDGE_INSET, EDGE_INSET, EDGE_INSET)\nPADDING = 8\nWINDOW_WIDTH = 500\nWINDOW_HEIGHT = 600\n\n\nclass ConfirmationWindow(NSObject):\n    \"\"\"Confirmation Window to confirm text before copying to clipboard\"\"\"\n\n    def init(self):\n        \"\"\"Initialize the ConfirmationWindow\"\"\"\n        self = objc.super(ConfirmationWindow, self).init()\n        if self is None:\n            return None\n        return self\n\n    @python_method\n    def create_window(self) -> NSWindow:\n        \"\"\"Create the NSWindow object\"\"\"\n        # use @python_method decorator to tell objc this is called using python\n        # conventions, not objc conventions\n        self.window = gui.window(\n            \"Textinator\",\n            (WINDOW_WIDTH, WINDOW_HEIGHT),\n            mask=AppKit.NSWindowStyleMaskTitled | AppKit.NSWindowStyleMaskClosable,\n        )\n        self.main_view = gui.main_view(\n            self.window, padding=PADDING, edge_inset=EDGE_INSETS\n        )\n\n        self.text_view = gui.text_view(\n            size=(WINDOW_WIDTH - 2 * EDGE_INSET, WINDOW_HEIGHT - 50)\n        )\n        self.main_view.append(self.text_view)\n        gui.constrain_to_parent_width(self.text_view, edge_inset=EDGE_INSET)\n        self.hstack = gui.hstack(align=AppKit.NSLayoutAttributeCenterY)\n        self.main_view.append(self.hstack)\n        self.button_cancel = gui.button(\"Cancel\", self, self.buttonCancel_)\n        self.button_copy = gui.button(\n            \"Copy to clipboard\", self, self.buttonCopyToClipboard_\n        )\n        self.button_copy.setKeyEquivalent_(\"\\r\")  # Return key\n        self.button_copy.setKeyEquivalentModifierMask_(0)  # No modifier keys\n        self.hstack.extend([self.button_cancel, self.button_copy])\n        gui.constrain_trailing_anchor_to_parent(self.hstack, edge_inset=EDGE_INSET)\n\n    @python_method\n    def show(self, text: str, app: Textinator):\n        \"\"\"Create and show the window\"\"\"\n\n        if not hasattr(self, \"window\"):\n            self.create_window()\n\n        self.app = app\n        self.log = app.log\n\n        with objc.autorelease_pool():\n            self.log(f\"Showing confirmation window with text: {text}\")\n            self.text_view.setString_(text)\n            self.window.makeKeyAndOrderFront_(None)\n            self.window.setIsVisible_(True)\n            self.window.setLevel_(AppKit.NSFloatingWindowLevel + 1)\n            self.window.setReleasedWhenClosed_(False)\n            self.window.makeFirstResponder_(self.button_copy)\n            return self.window\n\n    def buttonCancel_(self, sender):\n        \"\"\"Cancel button action\"\"\"\n        self.log(\"Cancel button clicked, closing window without copying text\")\n        self.window.close()\n\n    def buttonCopyToClipboard_(self, sender):\n        \"\"\"Copy to clipboard button action\"\"\"\n        text = self.text_view.string()\n        self.log(f\"Text to copy: {text}\")\n        if self.app.append.state:\n            clipboard_text = (\n                self.app.pasteboard.paste() if self.app.pasteboard.has_text() else \"\"\n            )\n            clipboard_text = f\"{clipboard_text}\\n{text}\" if clipboard_text else text\n        else:\n            clipboard_text = text\n        self.log(f\"Setting clipboard text to: {clipboard_text}\")\n        self.app.pasteboard.copy(clipboard_text)\n        self.window.close()\n"
  },
  {
    "path": "src/loginitems.py",
    "content": "\"\"\"Utilities for working with System Preferences > Users & Groups > Login Items on macOS.\"\"\"\n\nfrom typing import List\n\nimport applescript\n\n__all__ = [\"add_login_item\", \"list_login_items\", \"remove_login_item\"]\n\n# The following functions are used to manipulate the Login Items list in System Preferences\n# To use these, your app must include the com.apple.security.automation.apple-events entitlement\n# in its entitlements file during signing and must have the NSAppleEventsUsageDescription key in\n# its Info.plist file\n# These functions use AppleScript to interact with System Preferences. I know of no other way to\n# do this programmatically from Python.  If you know of a better way, please let me know!\n\n\ndef add_login_item(app_name: str, app_path: str, hidden: bool = False):\n    \"\"\"Add app to login items\"\"\"\n    scpt = (\n        'tell application \"System Events\" to make login item at end with properties '\n        + f'{{name:\"{app_name}\", path:\"{app_path}\", hidden:{\"true\" if hidden else \"false\"}}}'\n    )\n    applescript.AppleScript(scpt).run()\n\n\ndef remove_login_item(app_name: str):\n    \"\"\"Remove app from login items\"\"\"\n    scpt = f'tell application \"System Events\" to delete login item \"{app_name}\"'\n    applescript.AppleScript(scpt).run()\n\n\ndef list_login_items() -> List[str]:\n    \"\"\"Return list of login items\"\"\"\n    scpt = 'tell application \"System Events\" to get the name of every login item'\n    return applescript.AppleScript(scpt).run()\n"
  },
  {
    "path": "src/macvision.py",
    "content": "\"\"\"Use macOS Vision API to detect text and QR codes in images\"\"\"\n\nfrom typing import List, Optional, Tuple\n\nimport objc\nimport Quartz\nimport Vision\nfrom Foundation import NSURL, NSDictionary, NSLog\n\nfrom utils import get_mac_os_version\n\n__all__ = [\n    \"ciiimage_from_file\",\n    \"detect_qrcodes_in_ciimage\",\n    \"detect_qrcodes_in_file\",\n    \"detect_text_in_ciimage\",\n    \"detect_text_in_file\",\n    \"get_supported_vision_languages\",\n]\n\n\ndef get_supported_vision_languages() -> Tuple[Tuple[str], Tuple[str]]:\n    \"\"\"Get supported languages for text detection from Vision framework.\n\n    Returns: Tuple of ((language code), (error))\n    \"\"\"\n\n    with objc.autorelease_pool():\n        revision = Vision.VNRecognizeTextRequestRevision1\n        if get_mac_os_version() >= (\"11\", \"0\", \"0\"):\n            revision = Vision.VNRecognizeTextRequestRevision2\n\n        if get_mac_os_version() < (\"12\", \"0\", \"0\"):\n            return Vision.VNRecognizeTextRequest.supportedRecognitionLanguagesForTextRecognitionLevel_revision_error_(\n                Vision.VNRequestTextRecognitionLevelAccurate, revision, None\n            )\n\n        results = []\n        handler = make_request_handler(results)\n        textRequest = Vision.VNRecognizeTextRequest.alloc().initWithCompletionHandler_(\n            handler\n        )\n        return textRequest.supportedRecognitionLanguagesAndReturnError_(None)\n\n\ndef ciimage_from_file(filepath: str) -> Quartz.CIImage:\n    \"\"\"Create a Quartz.CIImage from a file\n\n    Args:\n        filepath: path to the image file\n\n    Returns:\n        Quartz.CIImage\n    \"\"\"\n    with objc.autorelease_pool():\n        input_url = NSURL.fileURLWithPath_(filepath)\n        return Quartz.CIImage.imageWithContentsOfURL_(input_url)\n\n\ndef detect_text_in_file(\n    img_path: str,\n    orientation: Optional[int] = None,\n    languages: Optional[List[str]] = None,\n) -> List[Tuple[str, float]]:\n    \"\"\"process image file at img_path with VNRecognizeTextRequest and return list of results\n\n    Args:\n        img_path: path to the image file\n        orientation: optional EXIF orientation (if known, passing orientation may improve quality of results)\n        languages: optional languages to use for text detection as list of ISO language code strings; default is [\"en-US\"]\n\n    Returns:\n        List of results where each result is a list of [text, confidence]\n    \"\"\"\n    input_image = ciimage_from_file(img_path)\n    return detect_text_in_ciimage(input_image, orientation, languages)\n\n\ndef detect_text_in_ciimage(\n    image: Quartz.CIImage,\n    orientation: Optional[int] = None,\n    languages: Optional[List[str]] = None,\n) -> List[Tuple[str, float]]:\n    \"\"\"process CIImage with VNRecognizeTextRequest and return list of results\n\n    This code originally developed for https://github.com/RhetTbull/osxphotos\n\n    Args:\n        image: CIIImage to process\n        orientation: optional EXIF orientation (if known, passing orientation may improve quality of results)\n        languages: optional languages to use for text detection as list of ISO language code strings; default is [\"en-US\"]\n\n    Returns:\n        List of results where each result is a list of [text, confidence]\n    \"\"\"\n    with objc.autorelease_pool():\n        vision_options = NSDictionary.dictionaryWithDictionary_({})\n        if orientation is None:\n            vision_handler = (\n                Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(\n                    image, vision_options\n                )\n            )\n        elif 1 <= orientation <= 8:\n            vision_handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_orientation_options_(\n                image, orientation, vision_options\n            )\n        else:\n            raise ValueError(\"orientation must be between 1 and 8\")\n        results = []\n        handler = make_request_handler(results)\n        vision_request = (\n            Vision.VNRecognizeTextRequest.alloc().initWithCompletionHandler_(handler)\n        )\n        languages = languages or [\"en-US\"]\n        vision_request.setRecognitionLanguages_(languages)\n        vision_request.setUsesLanguageCorrection_(True)\n        success, error = vision_handler.performRequests_error_([vision_request], None)\n        if not success:\n            raise ValueError(f\"Vision request failed: {error}\")\n\n        return [(str(result[0]), float(result[1])) for result in results]\n\n\ndef make_request_handler(results):\n    \"\"\"results: list to store results\"\"\"\n    if not isinstance(results, list):\n        raise ValueError(\"results must be a list\")\n\n    def handler(request, error):\n        if error:\n            NSLog(f\"Error! {error}\")\n        else:\n            observations = request.results()\n            for text_observation in observations:\n                recognized_text = text_observation.topCandidates_(1)[0]\n                results.append([recognized_text.string(), recognized_text.confidence()])\n\n    return handler\n\n\ndef detect_qrcodes_in_file(img_path: str) -> List[str]:\n    \"\"\"Detect QR Codes in image files using CIDetector and return text of the found QR Codes\n\n    Args:\n        img_path: path to the image file\n\n    Returns:\n        List of QR Code payload texts found in the image\n    \"\"\"\n\n    input_image = ciimage_from_file(img_path)\n    return detect_qrcodes_in_ciimage(input_image)\n\n\ndef detect_qrcodes_in_ciimage(image: Quartz.CIImage) -> List[str]:\n    \"\"\"Detect QR Codes in image using CIDetector and return text of the found QR Codes\n\n    Args:\n        input_image: CIImage to process\n\n    Returns:\n        List of QR Code payload texts found in the image\n    \"\"\"\n\n    with objc.autorelease_pool():\n        context = Quartz.CIContext.contextWithOptions_(None)\n        options = NSDictionary.dictionaryWithDictionary_(\n            {\"CIDetectorAccuracy\": Quartz.CIDetectorAccuracyHigh}\n        )\n        detector = Quartz.CIDetector.detectorOfType_context_options_(\n            Quartz.CIDetectorTypeQRCode, context, options\n        )\n\n        results = []\n        features = detector.featuresInImage_(image)\n\n        if not features:\n            return []\n        for idx in range(features.count()):\n            feature = features.objectAtIndex_(idx)\n            results.append(feature.messageString())\n        return results\n"
  },
  {
    "path": "src/pasteboard.py",
    "content": "\"\"\"macOS Pasteboard/Clipboard access using native APIs\n\nAuthor: Rhet Turnbull <rturnbull+git@gmail.com>\n\nLicense: MIT License, copyright 2022 Rhet Turnbull\n\nOriginal Source: https://github.com/RhetTbull/textinator\n\nVersion: 1.1.0, 2022-10-26\n\"\"\"\n\nimport os\nimport typing as t\n\nfrom AppKit import (\n    NSPasteboard,\n    NSPasteboardTypePNG,\n    NSPasteboardTypeString,\n    NSPasteboardTypeTIFF,\n)\nfrom Foundation import NSData\n\n# shortcuts for types\nPNG = \"PNG\"\nTIFF = \"TIFF\"\n\n__all__ = [\"Pasteboard\", \"PasteboardTypeError\", \"PNG\", \"TIFF\"]\n\n\nclass PasteboardError(Exception):\n    \"\"\"Base class for Pasteboard exceptions\"\"\"\n\n    ...\n\n\nclass PasteboardTypeError(PasteboardError):\n    \"\"\"Invalid type specified\"\"\"\n\n    ...\n\n\nclass Pasteboard:\n    \"\"\"macOS Pasteboard/Clipboard Class\"\"\"\n\n    def __init__(self):\n        self.pasteboard = NSPasteboard.generalPasteboard()\n        self._change_count = self.pasteboard.changeCount()\n\n    def copy(self, text):\n        \"\"\"Copy text to clipboard\n\n        Args:\n            text (str): Text to copy to clipboard\n        \"\"\"\n        self.set_text(text)\n\n    def paste(self):\n        \"\"\"Retrieve text from clipboard\n\n        Returns: str\n        \"\"\"\n        return self.get_text()\n\n    def append(self, text: str):\n        \"\"\"Append text to clipboard\n\n        Args:\n            text (str): Text to append to clipboard\n        \"\"\"\n        new_text = self.get_text() + text\n        self.set_text(new_text)\n\n    def clear(self):\n        \"\"\"Clear Clipboard\"\"\"\n        self.pasteboard.clearContents()\n        self._change_count = self.pasteboard.changeCount()\n\n    def copy_image(self, filename: t.Union[str, os.PathLike], format: str):\n        \"\"\"Copy image to clipboard from filename\n\n        Args:\n            filename (os.PathLike): Filename of image to copy to clipboard\n            format (str): Format of image to copy, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        self.set_image(filename, format)\n\n    def paste_image(\n        self,\n        filename: t.Union[str, os.PathLike],\n        format: str,\n        overwrite: bool = False,\n    ):\n        \"\"\"Paste image from clipboard to filename in PNG format\n\n        Args:\n            filename (os.PathLike): Filename of image to paste to\n            format (str): Format of image to paste, \"PNG\" or \"TIFF\"\n            overwrite (bool): Overwrite existing file\n\n        Raises:\n            FileExistsError: If file exists and overwrite is False\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        self.get_image(filename, format, overwrite)\n\n    def set_text(self, text: str):\n        \"\"\"Set text on clipboard\n\n        Args:\n            text (str): Text to set on clipboard\n        \"\"\"\n        self.pasteboard.clearContents()\n        self.pasteboard.setString_forType_(text, NSPasteboardTypeString)\n        self._change_count = self.pasteboard.changeCount()\n\n    def get_text(self) -> str:\n        \"\"\"Return text from clipboard\n\n        Returns: str\n        \"\"\"\n        return self.pasteboard.stringForType_(NSPasteboardTypeString) or \"\"\n\n    def get_image(\n        self,\n        filename: t.Union[str, os.PathLike],\n        format: str,\n        overwrite: bool = False,\n    ):\n        \"\"\"Save image from clipboard to filename in PNG format\n\n        Args:\n            filename (os.PathLike): Filename of image to save to\n            format (str): Format of image to save, \"PNG\" or \"TIFF\"\n            overwrite (bool): Overwrite existing file\n\n        Raises:\n            FileExistsError: If file exists and overwrite is False\n            PasteboardTypeError: If format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        if not isinstance(filename, str):\n            filename = str(filename)\n\n        if not overwrite and os.path.exists(filename):\n            raise FileExistsError(f\"File '{filename}' already exists\")\n\n        data = self.get_image_data(format)\n        data.writeToFile_atomically_(filename, True)\n\n    def set_image(self, filename: t.Union[str, os.PathLike], format: str):\n        \"\"\"Set image on clipboard from file in either PNG or TIFF format\n\n        Args:\n            filename (os.PathLike): Filename of image to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        data = NSData.dataWithContentsOfFile_(filename)\n        self.set_image_data(data, format)\n\n    def get_image_data(self, format: str) -> NSData:\n        \"\"\"Return image data from clipboard as NSData in PNG or TIFF format\n\n        Args:\n            format (str): Format of image to return, \"PNG\" or \"TIFF\"\n\n        Returns: NSData of image in PNG or TIFF format\n\n        Raises:\n            PasteboardTypeError if clipboard does not contain image in the specified type or type is invalid\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        pb_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF\n        if pb_type == NSPasteboardTypePNG and not self._has_png():\n            raise PasteboardTypeError(\"Clipboard does not contain PNG image\")\n        return self.pasteboard.dataForType_(pb_type)\n\n    def set_image_data(self, image_data: NSData, format: str):\n        \"\"\"Set image data on clipboard from NSData in a supported image format\n\n        Args:\n            image_data (NSData): Image data to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n\n        Raises: PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        format_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF\n        self.pasteboard.clearContents()\n        self.pasteboard.setData_forType_(image_data, format_type)\n        self._change_count = self.pasteboard.changeCount()\n\n    def set_text_and_image(\n        self, text: str, filename: t.Union[str, os.PathLike], format: str\n    ):\n        \"\"\"Set both text from str and image from file in either PNG or TIFF format\n\n        Args:\n            text (str): Text to set on clipboard\n            filename (os.PathLike): Filename of image to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        data = NSData.dataWithContentsOfFile_(filename)\n        self.set_text_and_image_data(text, data, format)\n\n    def set_text_and_image_data(self, text: str, image_data: NSData, format: str):\n        \"\"\"Set both text and image data on clipboard from NSData in a supported image format\n\n        Args:\n            text (str): Text to set on clipboard\n            image_data (NSData): Image data to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n\n        Raises: PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        self.set_image_data(image_data, format)\n        self.pasteboard.setString_forType_(text, NSPasteboardTypeString)\n        self._change_count = self.pasteboard.changeCount()\n\n    def has_changed(self) -> bool:\n        \"\"\"Return True if clipboard has been changed by another process since last check\n\n        Returns: bool\n        \"\"\"\n        if self.pasteboard.changeCount() != self._change_count:\n            self._change_count = self.pasteboard.changeCount()\n            return True\n        return False\n\n    def has_image(self, format: t.Optional[str] = None) -> bool:\n        \"\"\"Return True if clipboard has image otherwise False\n\n        Args:\n            format (str): Format of image to check for, \"PNG\" or \"TIFF\" or None to check for any image\n\n        Returns:\n            True if clipboard has image otherwise False\n\n        Raises:\n            PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format is None:\n            return self.pasteboard.types().containsObject_(\n                NSPasteboardTypeTIFF\n            ) or self.pasteboard.types().containsObject_(NSPasteboardTypePNG)\n        elif format == PNG:\n            return self._has_png()\n        elif format == TIFF:\n            return self._has_tiff()\n        else:\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n    def has_text(self) -> bool:\n        \"\"\"Return True if clipboard has text, otherwise False\n\n        Returns: bool\n        \"\"\"\n        return self.pasteboard.types().containsObject_(NSPasteboardTypeString)\n\n    def _has_png(self) -> bool:\n        \"\"\"Return True if clipboard can paste PNG image otherwise False\n\n        Returns: bool\n        \"\"\"\n        return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypePNG]))\n\n    def _has_tiff(self) -> bool:\n        \"\"\"Return True if clipboard can paste TIFF image otherwise False\n\n        Returns: bool\n        \"\"\"\n        return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypeTIFF]))\n"
  },
  {
    "path": "src/textinator.py",
    "content": "\"\"\"Simple MacOS menu bar / status bar app that automatically perform text detection on screenshots.\n\nAlso detects text on clipboard images and image files via the Services menu.\n\nRuns on Catalina (10.15) and later.\n\"\"\"\n\nimport contextlib\nimport datetime\nimport plistlib\nimport typing as t\n\nimport objc\nimport Quartz\nimport rumps\nfrom AppKit import NSApplication, NSPasteboardTypeFileURL\nfrom Foundation import (\n    NSURL,\n    NSLog,\n    NSMetadataQuery,\n    NSMetadataQueryDidFinishGatheringNotification,\n    NSMetadataQueryDidStartGatheringNotification,\n    NSMetadataQueryDidUpdateNotification,\n    NSMetadataQueryGatheringProgressNotification,\n    NSNotificationCenter,\n    NSObject,\n    NSPredicate,\n    NSString,\n    NSUTF8StringEncoding,\n)\n\nfrom confirmation_window import ConfirmationWindow\nfrom loginitems import add_login_item, list_login_items, remove_login_item\nfrom macvision import (\n    ciimage_from_file,\n    detect_qrcodes_in_ciimage,\n    detect_text_in_ciimage,\n    get_supported_vision_languages,\n)\nfrom pasteboard import TIFF, Pasteboard\nfrom utils import get_app_path, get_screenshot_location, verify_directory_access\n\n# do not manually change the version; use bump2version per the README\n__version__ = \"0.10.1\"\n\nAPP_NAME = \"Textinator\"\nAPP_ICON = \"icon.png\"\nAPP_ICON_PAUSED = \"icon_paused.png\"\n\n# default confidence threshold for text detection\nCONFIDENCE = {\"LOW\": 0.3, \"MEDIUM\": 0.5, \"HIGH\": 0.8}\nCONFIDENCE_DEFAULT = \"LOW\"\n\n# default language for text detection\nLANGUAGE_DEFAULT = \"en-US\"\nLANGUAGE_ENGLISH = \"en-US\"\n\n# where to store saved state, will reside in Application Support/APP_NAME\nCONFIG_FILE = f\"{APP_NAME}.plist\"\n\n# optional logging to file if debug enabled (will always log to Console via NSLog)\nLOG_FILE = f\"{APP_NAME}.log\"\n\n# how often (in seconds) to check for new screenshots on the clipboard\nCLIPBOARD_CHECK_INTERVAL = 2\n\n\nclass Textinator(rumps.App):\n    \"\"\"MacOS Menu Bar App to automatically perform text detection on screenshots.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        super(Textinator, self).__init__(*args, **kwargs)\n\n        # set \"debug\" to true in the config file to enable debug logging\n        self._debug = False\n\n        # pause / resume text detection\n        self._paused = False\n\n        # set the icon to a PNG file in the current directory\n        # this immediately updates the menu bar icon\n        # py2app will place the icon in the app bundle Resources folder\n        self.icon = APP_ICON\n\n        # ensure icon matches menu bar dark/light state\n        self.template = True\n\n        # the log method uses NSLog to log to the unified log\n        self.log(\"started\")\n\n        # get list of supported languages for language menu\n        languages, _ = get_supported_vision_languages()\n        languages = languages or [LANGUAGE_DEFAULT]\n        self.log(f\"supported languages: {languages}\")\n        self.recognition_language = (\n            LANGUAGE_DEFAULT if LANGUAGE_DEFAULT in languages else languages[0]\n        )\n\n        # menus\n        self.confidence = rumps.MenuItem(\"Text Detection Confidence Threshold\")\n        self.confidence_low = rumps.MenuItem(\"Low\", self.on_confidence)\n        self.confidence_medium = rumps.MenuItem(\"Medium\", self.on_confidence)\n        self.confidence_high = rumps.MenuItem(\"High\", self.on_confidence)\n        self.language = rumps.MenuItem(\"Text Recognition Language\")\n        for language in languages:\n            self.language.add(rumps.MenuItem(language, self.on_language))\n        self.language_english = rumps.MenuItem(\"Always Detect English\", self.on_toggle)\n        self.detect_clipboard = rumps.MenuItem(\n            \"Detect Text in Images on Clipboard\", self.on_toggle\n        )\n        self.qrcodes = rumps.MenuItem(\"Detect QR Codes\", self.on_toggle)\n        self.pause = rumps.MenuItem(\"Pause Text Detection\", self.on_pause)\n        self.show_notification = rumps.MenuItem(\"Notification\", self.on_toggle)\n        self.linebreaks = rumps.MenuItem(\"Keep Linebreaks\", self.on_toggle)\n        self.append = rumps.MenuItem(\"Append to Clipboard\", self.on_toggle)\n        self.clear_clipboard = rumps.MenuItem(\n            \"Clear Clipboard\", self.on_clear_clipboard\n        )\n        self.confirmation = rumps.MenuItem(\"Confirm Clipboard Changes\", self.on_toggle)\n        self.show_last_detetection = rumps.MenuItem(\n            \"Show Last Text Detection\", self.on_show_last_detection\n        )\n        self.start_on_login = rumps.MenuItem(\n            f\"Start {APP_NAME} on Login\", self.on_start_on_login\n        )\n        self.about = rumps.MenuItem(f\"About {APP_NAME}\", self.on_about)\n        self.quit = rumps.MenuItem(f\"Quit {APP_NAME}\", self.on_quit)\n        self.menu = [\n            [\n                self.confidence,\n                [self.confidence_low, self.confidence_medium, self.confidence_high],\n            ],\n            self.language,\n            self.language_english,\n            self.detect_clipboard,\n            self.pause,\n            None,\n            self.qrcodes,\n            None,\n            self.show_notification,\n            None,\n            self.linebreaks,\n            self.append,\n            self.clear_clipboard,\n            self.confirmation,\n            self.show_last_detetection,\n            None,\n            self.start_on_login,\n            self.about,\n            self.quit,\n        ]\n\n        # load config from plist file and init menu state\n        self.load_config()\n\n        # set icon to auto switch between light and dark mode\n        self.template = True\n\n        # track all screenshots already seen\n        self._screenshots = {}\n\n        # Need to verify access to the screenshot folder; default is ~/Desktop\n        # When this is called for the first time, the user will be prompted to grant access\n        # and shown the message assigned to NSDesktopFolderUsageDescription in the Info.plist file\n        self.verify_screenshot_access()\n\n        # initialize the service provider class which handles actions from the Services menu\n        # pass reference to self so the service provider can access the app's methods and state\n        self.service_provider = ServiceProvider.alloc().initWithApp_(self)\n        # register the service provider with the Services menu\n        NSApplication.sharedApplication().setServicesProvider_(self.service_provider)\n\n        # Create a Pasteboard instance which will be used by clipboard_watcher() to detect changes\n        # to the pasteboard (which everyone but Apple calls the clipboard)\n        self.pasteboard = Pasteboard()\n\n        # will hold ConfirmationWindow if needed\n        self.confirmation_window = None\n\n        # last detected text is stored\n        self.last_detected_text = None\n\n        # start the spotlight query\n        self.start_query()\n\n    def log(self, msg: str):\n        \"\"\"Log a message to unified log.\"\"\"\n        NSLog(f\"{APP_NAME} {__version__} {msg}\")\n\n        # if debug set in config, also log to file\n        # file will be created in Application Support folder\n        if self._debug:\n            with self.open(LOG_FILE, \"a\") as f:\n                f.write(f\"{datetime.datetime.now().isoformat()} - {msg}\\n\")\n\n    def verify_screenshot_access(self):\n        \"\"\"Verify screenshot access and alert user if needed\"\"\"\n        if screenshot_location := get_screenshot_location():\n            if verify_directory_access(screenshot_location):\n                self.log(f\"screenshot location access ok: {screenshot_location}\")\n            else:\n                self.log(\n                    f\"Error: could not access default screenshot location {screenshot_location}\"\n                )\n                rumps.alert(\n                    f\"Error: {APP_NAME} could not access the default screenshot location {screenshot_location} \\n\"\n                    f\"You may need to enable Full Disk Access for {APP_NAME} in System Settings...>Privacy & Security> Full Disk Access\"\n                )\n        else:\n            self.log(f\"Error: could not determine default screenshot location\")\n            rumps.alert(\n                f\"Error: {APP_NAME} could not determine the default screenshot location. \"\n            )\n\n    def load_config(self):\n        \"\"\"Load config from plist file in Application Support folder.\n\n        The usual app convention is to store config in ~/Library/Preferences but\n        rumps.App.open() provides a convenient self.open() method to access the\n        Application Support folder so that's what is used here.\n\n        The config info is saved as a plist file (property list) which is an Apple standard\n        for storing structured data. JSON or another format could be used but I stuck with\n        plist so that the config file could be easily edited manually if needed and that's\n        what is expected by macOS apps.\n        \"\"\"\n        self.config = {}\n        with contextlib.suppress(FileNotFoundError):\n            with self.open(CONFIG_FILE, \"rb\") as f:\n                with contextlib.suppress(Exception):\n                    # don't crash if config file is malformed\n                    self.config = plistlib.load(f)\n        if not self.config:\n            # file didn't exist or was malformed, create a new one\n            # initialize config with default values\n            self.config = {\n                \"confidence\": CONFIDENCE_DEFAULT,\n                \"linebreaks\": True,\n                \"append\": False,\n                \"notification\": True,\n                \"language\": self.recognition_language,\n                \"always_detect_english\": True,\n                \"detect_qrcodes\": False,\n                \"start_on_login\": False,\n                \"confirmation\": False,\n                \"detect_clipboard\": True,\n            }\n        self.log(f\"loaded config: {self.config}\")\n\n        # update the menu state to match the loaded config\n        self.append.state = self.config.get(\"append\", False)\n        self.linebreaks.state = self.config.get(\"linebreaks\", True)\n        self.show_notification.state = self.config.get(\"notification\", True)\n        self.set_confidence_state(self.config.get(\"confidence\", CONFIDENCE_DEFAULT))\n        self.recognition_language = self.config.get(\n            \"language\", self.recognition_language\n        )\n        self.set_language_menu_state(self.recognition_language)\n        self.language_english.state = self.config.get(\"always_detect_english\", True)\n        self.detect_clipboard.state = self.config.get(\"detect_clipboard\", True)\n        self.confirmation.state = self.config.get(\"confirmation\", False)\n        self.qrcodes.state = self.config.get(\"detect_qrcodes\", False)\n        self._debug = self.config.get(\"debug\", False)\n        self.start_on_login.state = self.config.get(\"start_on_login\", False)\n\n        # save config because it may have been updated with default values\n        self.save_config()\n\n    def save_config(self):\n        \"\"\"Write config to plist file in Application Support folder.\n\n        See docstring on load_config() for additional information.\n        \"\"\"\n        self.config[\"linebreaks\"] = self.linebreaks.state\n        self.config[\"append\"] = self.append.state\n        self.config[\"notification\"] = self.show_notification.state\n        self.config[\"confidence\"] = self.get_confidence_state()\n        self.config[\"language\"] = self.recognition_language\n        self.config[\"always_detect_english\"] = self.language_english.state\n        self.config[\"detect_clipboard\"] = self.detect_clipboard.state\n        self.config[\"confirmation\"] = self.confirmation.state\n        self.config[\"detect_qrcodes\"] = self.qrcodes.state\n        self.config[\"debug\"] = self._debug\n        self.config[\"start_on_login\"] = self.start_on_login.state\n        with self.open(CONFIG_FILE, \"wb+\") as f:\n            plistlib.dump(self.config, f)\n        self.log(f\"saved config: {self.config}\")\n\n    def on_language(self, sender):\n        \"\"\"Change language.\"\"\"\n        self.recognition_language = sender.title\n        self.set_language_menu_state(sender.title)\n        self.save_config()\n\n    def on_pause(self, sender):\n        \"\"\"Pause/resume text detection.\"\"\"\n        if self._paused:\n            self._paused = False\n            self.icon = APP_ICON\n            sender.title = \"Pause Text Detection\"\n        else:\n            self._paused = True\n            self.icon = APP_ICON_PAUSED\n            sender.title = \"Resume text detection\"\n\n    def on_toggle(self, sender):\n        \"\"\"Toggle sender state.\"\"\"\n        sender.state = not sender.state\n        self.save_config()\n\n    def on_clear_clipboard(self, sender):\n        \"\"\"Clear the clipboard\"\"\"\n        self.pasteboard.clear()\n\n    def on_confidence(self, sender):\n        \"\"\"Change confidence threshold.\"\"\"\n        self.clear_confidence_state()\n        sender.state = True\n        self.save_config()\n\n    def on_show_last_detection(self, sender):\n        \"\"\"Show last detected text\"\"\"\n        self.confirmation_window = (\n            self.confirmation_window or ConfirmationWindow.alloc().init()\n        )\n        self.confirmation_window.show(self.last_detected_text or \"\", self)\n\n    def clear_confidence_state(self):\n        \"\"\"Clear confidence menu state\"\"\"\n        self.confidence_low.state = False\n        self.confidence_medium.state = False\n        self.confidence_high.state = False\n\n    def get_confidence_state(self):\n        \"\"\"Get confidence threshold state.\"\"\"\n        if self.confidence_low.state:\n            return \"LOW\"\n        elif self.confidence_medium.state:\n            return \"MEDIUM\"\n        elif self.confidence_high.state:\n            return \"HIGH\"\n        else:\n            return CONFIDENCE_DEFAULT\n\n    def set_confidence_state(self, confidence):\n        \"\"\"Set confidence threshold state.\"\"\"\n        self.clear_confidence_state()\n        if confidence == \"LOW\":\n            self.confidence_low.state = True\n        elif confidence == \"MEDIUM\":\n            self.confidence_medium.state = True\n        elif confidence == \"HIGH\":\n            self.confidence_high.state = True\n        else:\n            raise ValueError(f\"Unknown confidence threshold: {confidence}\")\n\n    def set_language_menu_state(self, language):\n        \"\"\"Set the language menu state\"\"\"\n        for item in self.language.values():\n            item.state = False\n            if item.title == language:\n                item.state = True\n\n    def on_start_on_login(self, sender):\n        \"\"\"Configure app to start on login or toggle this setting.\"\"\"\n        self.start_on_login.state = not self.start_on_login.state\n        if self.start_on_login.state:\n            app_path = get_app_path()\n            self.log(f\"adding app to login items with path {app_path}\")\n            if APP_NAME not in list_login_items():\n                add_login_item(APP_NAME, app_path, hidden=False)\n        else:\n            self.log(\"removing app from login items\")\n            if APP_NAME in list_login_items():\n                remove_login_item(APP_NAME)\n        self.save_config()\n\n    def on_about(self, sender):\n        \"\"\"Display about dialog.\"\"\"\n        rumps.alert(\n            title=f\"About {APP_NAME}\",\n            message=f\"{APP_NAME} Version {__version__}\\n\\n\"\n            f\"{APP_NAME} is a simple utility to recognize text in screenshots.\\n\\n\"\n            f\"{APP_NAME} is open source and licensed under the MIT license.\\n\\n\"\n            \"Copyright 2022 by Rhet Turnbull\\n\"\n            \"https://github.com/RhetTbull/textinator\",\n            ok=\"OK\",\n        )\n\n    def on_quit(self, sender):\n        \"\"\"Cleanup before quitting.\"\"\"\n        self.log(\"quitting\")\n        NSNotificationCenter.defaultCenter().removeObserver_(self)\n        self.query.stopQuery()\n        self.query.setDelegate_(None)\n        self.query.release()\n        rumps.quit_application()\n\n    def start_query(self):\n        \"\"\"Start the NSMetdataQuery Spotlight query to monitor for screenshot files.\"\"\"\n        self.query = NSMetadataQuery.alloc().init()\n\n        # screenshots all have metadata property kMDItemIsScreenCapture set to 1\n        # this can be viewed with the command line tool mdls\n        self.query.setPredicate_(\n            NSPredicate.predicateWithFormat_(\"kMDItemIsScreenCapture = 1\")\n        )\n\n        # configure the query to post notifications, which our query_updated method will handle\n        nf = NSNotificationCenter.defaultCenter()\n        nf.addObserver_selector_name_object_(\n            self,\n            \"query_updated:\",\n            None,\n            self.query,\n        )\n        self.query.setDelegate_(self)\n        self.query.startQuery()\n\n    def initialize_screenshots(self, notif):\n        \"\"\"Track all screenshots already seen or that existed on app startup.\n\n        The Spotlight query will return *all* screenshots on the computer so track those results\n        when returned and only process new screenshots.\n        \"\"\"\n        results = notif.object().results()\n        for item in results:\n            path = item.valueForAttribute_(\n                \"kMDItemPath\"\n            ).stringByResolvingSymlinksInPath()\n            self._screenshots[path] = True\n\n    def process_screenshot(self, notif):\n        \"\"\"Process a new screenshot and detect text (and QR codes if requested).\"\"\"\n        results = notif.object().results()\n        for item in results:\n            path = item.valueForAttribute_(\n                \"kMDItemPath\"\n            ).stringByResolvingSymlinksInPath()\n\n            if path in self._screenshots:\n                # we've already seen this screenshot or screenshot existed at app startup, skip it\n                continue\n\n            if self._paused:\n                # don't process screenshots if paused but still add to seen list\n                self.log(f\"skipping screenshot because app is paused: {path}\")\n                self._screenshots[path] = \"__SKIPPED__\"\n                continue\n\n            self.log(f\"processing new screenshot: {path}\")\n\n            screenshot_image = ciimage_from_file(path)\n            if screenshot_image is None:\n                self.log(f\"failed to load screenshot image: {path}\")\n                continue\n\n            detected_text = self.process_image(screenshot_image)\n            self._screenshots[path] = detected_text\n            if self.show_notification.state:\n                self.notification(\n                    title=\"Processed Screenshot\",\n                    subtitle=f\"{path}\",\n                    message=(\n                        f\"Detected text: {detected_text}\"\n                        if detected_text\n                        else \"No text detected\"\n                    ),\n                )\n\n    def process_image(self, image: Quartz.CIImage) -> str:\n        \"\"\"Process an image and detect text (and QR codes if requested).\n        Updates the clipboard with the detected text.\n\n        Args:\n            image: Quartz.CIImage\n\n        Returns:\n            String of detected text or empty string if no text detected.\n        \"\"\"\n        # if \"Always Detect English\" checked, add English to list of languages to detect\n        languages = (\n            [self.recognition_language, LANGUAGE_ENGLISH]\n            if self.language_english.state\n            and self.recognition_language != LANGUAGE_ENGLISH\n            else [self.recognition_language]\n        )\n        detected_text = detect_text_in_ciimage(image, languages=languages)\n        confidence = CONFIDENCE[self.get_confidence_state()]\n        text = \"\\n\".join(\n            result[0] for result in detected_text if result[1] >= confidence\n        )\n\n        if self.qrcodes.state:\n            # Also detect QR codes and copy the text from the QR code payload\n            if detected_qrcodes := detect_qrcodes_in_ciimage(image):\n                text = (\n                    text + \"\\n\" + \"\\n\".join(detected_qrcodes)\n                    if text\n                    else \"\\n\".join(detected_qrcodes)\n                )\n\n        if text:\n            if not self.linebreaks.state:\n                text = text.replace(\"\\n\", \" \")\n            self.last_detected_text = text\n\n            if self.append.state:\n                clipboard_text = (\n                    self.pasteboard.paste() if self.pasteboard.has_text() else \"\"\n                )\n                clipboard_text = f\"{clipboard_text}\\n{text}\" if clipboard_text else text\n            else:\n                clipboard_text = text\n\n            if self.confirmation.state:\n                # display confirmation dialog\n                verb = \"Append\" if self.append.state else \"Copy\"\n                self.confirmation_window = (\n                    self.confirmation_window or ConfirmationWindow.alloc().init()\n                )\n                self.confirmation_window.show(text, self)\n            else:\n                self.pasteboard.copy(clipboard_text)\n\n        return text\n\n    def query_updated_(self, notif):\n        \"\"\"Receives and processes notifications from the Spotlight query.\n        The trailing _ in the name is required by PyObjC to conform to Objective-C calling conventions.\n        Reference: https://pyobjc.readthedocs.io/en/latest/core/intro.html#underscores-and-lots-of-them\n        \"\"\"\n        if notif.name() == NSMetadataQueryDidStartGatheringNotification:\n            # The query has just started\n            self.log(\"search: query started\")\n        elif notif.name() == NSMetadataQueryDidFinishGatheringNotification:\n            # The query has just finished\n            # log all results so we don't try to do text detection on previous screenshots\n            self.log(\"search: finished gathering\")\n            self.initialize_screenshots(notif)\n        elif notif.name() == NSMetadataQueryGatheringProgressNotification:\n            # The query is still gathering results...\n            self.log(\"search: gathering progress\")\n        elif notif.name() == NSMetadataQueryDidUpdateNotification:\n            # There's a new result available\n            self.log(\"search: an update happened.\")\n            self.process_screenshot(notif)\n\n    @rumps.timer(CLIPBOARD_CHECK_INTERVAL)\n    def clipboard_watcher(self, sender):\n        \"\"\"Watch the clipboard (pasteboard) for changes.\n        Uses rumps.timer decorator to run every CLIPBOARD_CHECK_INTERVAL seconds.\n        The timer runs even if detect_clipboard is not checked or app is paused\n        but won't process images in those cases.\n        \"\"\"\n        if not self.detect_clipboard.state:\n            return\n\n        if self.pasteboard.has_changed() and self.pasteboard.has_image():\n            # image is on the pasteboard, process it\n            self.log(\"new image on clipboard\")\n            if self.pasteboard.has_text():\n                # some apps like Excel copy an image representation of the text to the clipboard\n                # in addition to the text, in this case do not do text detection, see #16\n                self.log(\"clipboard has text, skipping\")\n                return\n            if self._paused:\n                self.log(\"skipping clipboard image because app is paused\")\n                return\n            self.process_clipboard_image()\n\n    def process_clipboard_image(self):\n        \"\"\"Process the image on the clipboard.\"\"\"\n        if image_data := self.pasteboard.get_image_data(TIFF):\n            image = Quartz.CIImage.imageWithData_(image_data)\n            detected_text = self.process_image(image)\n            self.log(\"processed clipboard image\")\n            if self.show_notification.state:\n                self.notification(\n                    title=\"Processed Clipboard Image\",\n                    subtitle=\"\",\n                    message=(\n                        f\"Detected text: {detected_text}\"\n                        if detected_text\n                        else \"No text detected\"\n                    ),\n                )\n        else:\n            self.log(\"failed to get image data from pasteboard\")\n\n    def notification(self, title, subtitle, message):\n        \"\"\"Display a notification.\"\"\"\n        self.log(f\"notification: {title} - {subtitle} - {message}\")\n        rumps.notification(title, subtitle, message)\n\n\ndef serviceSelector(fn):\n    \"\"\"Decorator to convert a method to a selector to handle an NSServices message.\"\"\"\n    return objc.selector(fn, signature=b\"v@:@@o^@\")\n\n\ndef ErrorValue(e):\n    \"\"\"Handler for errors returned by the service.\"\"\"\n    NSLog(f\"{APP_NAME} {__version__} error: {e}\")\n    return e\n\n\nclass ServiceProvider(NSObject):\n    \"\"\"Service provider class to handle messages from the Services menu\n\n    Initialize with ServiceProvider.alloc().initWithApp_(app)\n    \"\"\"\n\n    app: t.Optional[Textinator] = None\n\n    def initWithApp_(self, app: Textinator):\n        self = objc.super(ServiceProvider, self).init()\n        self.app = app\n        return self\n\n    @serviceSelector\n    def detectTextInImage_userData_error_(\n        self, pasteboard, userdata, error\n    ) -> t.Optional[str]:\n        \"\"\"Detect text in an image on the clipboard.\n\n        This method will be called by the Services menu when the user selects \"Detect Text With Textinator\".\n        It is specified in the setup.py NSMessage attribute. The method name in NSMessage is `detectTextInImage`\n        but the actual Objective-C signature is `detectTextInImage:userData:error:` hence the matching underscores\n        in the python method name.\n\n        Args:\n            pasteboard: NSPasteboard object containing the URLs of the image files to process\n            userdata: Unused, passed by the Services menu as value of NSUserData attribute in setup.py;\n                can be used to pass additional data to the service if needed\n            error: Unused; in Objective-C, error is a pointer to an NSError object that will be set if an error occurs;\n                when using pyobjc, errors are returned as str values and the actual error argument is ignored.\n\n        Returns:\n            error: str value containing the error message if an error occurs, otherwise None\n\n        Note: because this method is explicitly invoked by the user via the Services menu, it will\n        be called and files processed even if the app is paused.\n\n        \"\"\"\n        self.app.log(\"detectTextInImage_userData_error_ called via Services menu\")\n\n        try:\n            for item in pasteboard.pasteboardItems():\n                # pasteboard will contain one or more URLs to image files passed by the Services menu\n                pb_url_data = item.dataForType_(NSPasteboardTypeFileURL)\n                pb_url = NSURL.URLWithString_(\n                    NSString.alloc().initWithData_encoding_(\n                        pb_url_data, NSUTF8StringEncoding\n                    )\n                )\n                self.app.log(f\"processing file from Services menu: {pb_url.path()}\")\n                image = Quartz.CIImage.imageWithContentsOfURL_(pb_url)\n                detected_text = self.app.process_image(image)\n                if self.app.show_notification.state:\n                    self.app.notification(\n                        title=\"Processed Image\",\n                        subtitle=f\"{pb_url.path()}\",\n                        message=(\n                            f\"Detected text: {detected_text}\"\n                            if detected_text\n                            else \"No text detected\"\n                        ),\n                    )\n        except Exception as e:\n            return ErrorValue(e)\n\n        return None\n\n\nif __name__ == \"__main__\":\n    Textinator(name=APP_NAME, quit_button=None).run()\n"
  },
  {
    "path": "src/utils.py",
    "content": "\"\"\"macOS specific utilities used by Textinator\"\"\"\n\nimport os\nimport platform\nfrom typing import Tuple\n\nimport objc\nfrom Foundation import (\n    NSURL,\n    NSBundle,\n    NSDesktopDirectory,\n    NSFileManager,\n    NSLog,\n    NSUserDefaults,\n    NSUserDomainMask,\n)\n\n__all__ = [\n    \"get_app_path\",\n    \"get_mac_os_version\",\n    \"get_screenshot_location\",\n    \"verify_directory_access\",\n    \"verify_screenshot_access\",\n]\n\n\ndef verify_directory_access(path: str) -> str | None:\n    \"\"\"Verify that the app has access to the specified directory\n\n    Args:\n        path: str path to the directory to verify access to.\n\n    Returns: path if access is verified, None otherwise.\n    \"\"\"\n    with objc.autorelease_pool():\n        path_url = NSURL.fileURLWithPath_(path)\n        (\n            directory_files,\n            error,\n        ) = NSFileManager.defaultManager().contentsOfDirectoryAtURL_includingPropertiesForKeys_options_error_(\n            path_url, [], 0, None\n        )\n        if error:\n            NSLog(f\"verify_directory_access: {error.localizedDescription()}\")\n            return None\n        return path\n\n\ndef get_screenshot_location() -> str:\n    \"\"\"Return path to the default location for screenshots\n\n    First checks the custom screenshot location from com.apple.screencapture.\n    If not set or inaccessible, assumes Desktop.\n\n    If the App has NSDesktopFolderUsageDescription set in Info.plist,\n    user will be prompted to grant Desktop access the first time this is run\n    if the screenshot location is the Desktop.\n\n    Returns: str path to the screenshot location.\n    \"\"\"\n    with objc.autorelease_pool():\n        # Check for custom screenshot location\n        screencapture_defaults = NSUserDefaults.alloc().initWithSuiteName_(\n            \"com.apple.screencapture\"\n        )\n        if custom_location := screencapture_defaults.stringForKey_(\"location\"):\n            return os.path.expanduser(custom_location)\n\n        # Fallback to Desktop if no custom location or if it's inaccessible\n        (\n            desktop_url,\n            error,\n        ) = NSFileManager.defaultManager().URLForDirectory_inDomain_appropriateForURL_create_error_(\n            NSDesktopDirectory, NSUserDomainMask, None, False, None\n        )\n        return str(desktop_url.path()) if not error else os.path.expanduser(\"~/Desktop\")\n\n\ndef verify_screenshot_access() -> str | None:\n    \"\"\"Verify that the app has access to the user's screenshot location or Desktop\n\n    First checks the custom screenshot location from com.apple.screencapture.\n    If not set or inaccessible, checks the Desktop.\n\n    If the App has NSDesktopFolderUsageDescription set in Info.plist,\n    user will be prompted to grant Desktop access the first time this is run.\n\n    Returns: path to screenshot location if access otherwise None\n    \"\"\"\n    with objc.autorelease_pool():\n        screenshot_location = get_screenshot_location()\n        return verify_directory_access(screenshot_location)\n\n\ndef get_mac_os_version() -> Tuple[str, str, str]:\n    \"\"\"Returns tuple of str in form (version, major, minor) containing OS version, e.g. 10.13.6 = (\"10\", \"13\", \"6\")\"\"\"\n    version = platform.mac_ver()[0].split(\".\")\n    if len(version) == 2:\n        (ver, major) = version\n        minor = \"0\"\n    elif len(version) == 3:\n        (ver, major, minor) = version\n    else:\n        raise (\n            ValueError(\n                f\"Could not parse version string: {platform.mac_ver()} {version}\"\n            )\n        )\n\n    # python might return 10.16 instead of 11.0 for Big Sur and above\n    if ver == \"10\" and int(major) >= 16:\n        ver = str(11 + int(major) - 16)\n        major = minor\n        minor = \"0\"\n\n    return (ver, major, minor)\n\n\ndef get_app_path() -> str:\n    \"\"\"Return path to the bundle containing this script\"\"\"\n    # Note: This must be called from an app bundle built with py2app or you'll get\n    # the path of the python interpreter instead of the actual app\n    return NSBundle.mainBundle().bundlePath()\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Test configuration for pytest for Textinator tests.\"\"\"\n\nimport os\nimport pathlib\nimport shutil\nimport tempfile\nimport time\nimport typing as t\nfrom contextlib import contextmanager\nfrom io import TextIOWrapper\n\nimport applescript\nimport CoreServices\nimport pytest\nfrom applescript import kMissingValue\nfrom osxmetadata.mditem import set_mditem_metadata\n\nfrom .loginitems import add_login_item, list_login_items, remove_login_item\nfrom .pasteboard import Pasteboard\n\n\ndef click_menu_item(menu_item: str, sub_menu_item: t.Optional[str] = None) -> bool:\n    \"\"\"Click menu_item in Textinator's status bar menu.\n\n    This uses AppleScript and System Events to click on the menu item.\n\n    Args:\n        menu_item: Name of menu item to click.\n        sub_menu_item: Name of sub menu item to click or None if no sub menu item.\n\n    Returns:\n        True if menu item was successfully clicked, False otherwise.\n\n    Note: in many status bar apps, the actual menu bar you want to click is menu bar 2;\n    menu bar 1 is the Apple menu. In RUMPS apps, it appears that the menu bar you want is\n    menu bar 1. This may be different for other apps.\n    \"\"\"\n    scpt = applescript.AppleScript(\n        \"\"\"\n    on click_menu_item(process_, menu_item_name_, submenu_item_name_)\n        try\n            tell application \"System Events\" to tell process process_\n                tell menu bar item 1 of menu bar 1\n                    click\n                    click menu item menu_item_name_ of menu 1\n                    if submenu_item_name_ is not missing value then\n                        click menu item submenu_item_name_ of menu 1 of menu item menu_item_name_ of menu 1\n                    end if\n                end tell\n            end tell\n        on error\n            return false\n        end try\n        return true\n    end click_menu_item\n    \"\"\"\n    )\n    sub_menu_item = sub_menu_item or kMissingValue\n    return_value = scpt.call(\"click_menu_item\", \"Textinator\", menu_item, sub_menu_item)\n    time.sleep(5)\n    return return_value\n\n\ndef click_window_button(window: int, button: int) -> bool:\n    \"\"\" \"Click a button in a Textinator window.\n\n    Args:\n        window: window number (1 = first window)\n        button: button number (1 = first button, if yes/no, 1 = yes, 2 = no)\n\n    Returns:\n        True if successful, False otherwise\n    \"\"\"\n    scpt = applescript.AppleScript(\n        \"\"\"\n        on click_window_button(process_, window_number_, button_number_)\n            try\n                tell application \"System Events\" to tell process process_\n                    tell button button_number_ of window window_number_\n                        click\n                    end tell\n                end tell\n            on error\n                return false\n            end try\n            return true\n        end click_window_button\n    \"\"\"\n    )\n    return scpt.call(\"click_window_button\", \"Textinator\", window, button)\n\n\ndef process_is_running(process_name: str) -> bool:\n    \"\"\"Return True if process_name is running, False otherwise\"\"\"\n    scpt = applescript.AppleScript(\n        \"\"\"\n        on process_is_running(process_name_)\n            tell application \"System Events\"\n                set process_list to (name of every process)\n            end tell\n            return process_name_ is in process_list\n        end process_is_running\n    \"\"\"\n    )\n    return scpt.call(\"process_is_running\", process_name)\n\n\n@contextmanager\ndef copy_to_desktop(filepath):\n    \"\"\"Fixture to copy file to Desktop in a temporary directory.\"\"\"\n    filepath = pathlib.Path(filepath)\n    desktop_path = pathlib.Path(\"~/Desktop\").expanduser()\n    with tempfile.TemporaryDirectory(dir=desktop_path, prefix=\"Textinator-\") as tempdir:\n        tempdir_path = pathlib.Path(tempdir)\n        shutil.copy(filepath, tempdir_path)\n        yield tempdir_path / filepath.name\n\n\ndef mark_screenshot(filepath: t.Union[str, pathlib.Path]) -> bool:\n    \"\"\"Mark a file as screenshot so Spotlight will index it.\n\n    Args:\n        filepath: Fully resolved path to file to mark as screenshot.\n\n    Returns:\n        True if file was marked as screenshot, False otherwise.\n\n    Note: This uses a private Apple API exposed by osxmetadata to set the appropriate metadata.\n    \"\"\"\n    filepath = filepath if isinstance(filepath, str) else str(filepath)\n    mditem = CoreServices.MDItemCreate(None, str(filepath))\n    return set_mditem_metadata(mditem, \"kMDItemIsScreenCapture\", True)\n\n\n@pytest.fixture\ndef pb():\n    \"\"\"Return pasteboard\"\"\"\n    return Pasteboard()\n\n\ndef app_support_dir() -> pathlib.Path:\n    \"\"\"Return path to Textinator's app support directory\"\"\"\n    return pathlib.Path(\"~/Library/Application Support/Textinator\").expanduser()\n\n\n@contextmanager\ndef log_file() -> TextIOWrapper:\n    \"\"\"Return Textinator's log file, opened for reading from end\"\"\"\n    log_filepath = app_support_dir() / \"Textinator.log\"\n    lf = log_filepath.open(\"r\")\n    lf.seek(0, os.SEEK_END)\n    yield lf\n    lf.close()\n\n\ndef backup_log():\n    \"\"\"Backup log file\"\"\"\n    log_path = app_support_dir() / \"Textinator.log\"\n    if log_path.exists():\n        log_path.rename(log_path.with_suffix(\".log.bak\"))\n\n\ndef restore_log():\n    \"\"\"Restore log file from backup\"\"\"\n    log_path = app_support_dir() / \"Textinator.log.bak\"\n    if log_path.exists():\n        log_path.rename(log_path.parent / log_path.stem)\n\n\ndef backup_plist():\n    \"\"\"Backup plist file\"\"\"\n    plist_path = app_support_dir() / \"Textinator.plist\"\n    if plist_path.exists():\n        plist_path.rename(plist_path.with_suffix(\".plist.bak\"))\n\n\ndef restore_plist():\n    \"\"\"Restore plist file from backup\"\"\"\n    plist_path = app_support_dir() / \"Textinator.plist.bak\"\n    if plist_path.exists():\n        plist_path.rename(plist_path.parent / plist_path.stem)\n\n\n@pytest.fixture(autouse=True, scope=\"session\")\ndef setup_teardown():\n    \"\"\"Fixture to execute asserts before and after test session is run\"\"\"\n    # setup\n    os.system(\"killall Textinator\")\n\n    # backup_log()\n    backup_plist()\n\n    shutil.copy(\"tests/data/Textinator.plist\", app_support_dir() / \"Textinator.plist\")\n\n    login_item = \"Textinator\" in list_login_items()\n    if login_item:\n        remove_login_item(\"Textinator\")\n\n    os.system(\"open -a Textinator\")\n    time.sleep(5)\n\n    yield  # run tests\n\n    # teardown\n    os.system(\"killall Textinator\")\n\n    # restore_log()\n    restore_plist()\n\n    if login_item:\n        add_login_item(\"Textinator\", \"/Applications/Textinator.app\", False)\n\n    os.system(\"open -a Textinator\")\n\n\n@pytest.fixture\ndef suspend_capture(pytestconfig):\n    \"\"\"Context manager fixture that suspends capture of stdout/stderr for the duration of the context manager.\"\"\"\n\n    class suspend_guard:\n        def __init__(self):\n            self.capmanager = pytestconfig.pluginmanager.getplugin(\"capturemanager\")\n\n        def __enter__(self):\n            self.capmanager.suspend_global_capture(in_=True)\n\n        def __exit__(self, _1, _2, _3):\n            self.capmanager.resume_global_capture()\n\n    yield suspend_guard()\n"
  },
  {
    "path": "tests/data/Textinator.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>always_detect_english</key>\n\t<integer>1</integer>\n\t<key>append</key>\n\t<integer>0</integer>\n\t<key>confidence</key>\n\t<string>LOW</string>\n\t<key>confirmation</key>\n\t<integer>0</integer>\n\t<key>debug</key>\n\t<true/>\n\t<key>detect_clipboard</key>\n\t<integer>1</integer>\n\t<key>detect_qrcodes</key>\n\t<integer>0</integer>\n\t<key>language</key>\n\t<string>en-US</string>\n\t<key>linebreaks</key>\n\t<integer>1</integer>\n\t<key>notification</key>\n\t<integer>1</integer>\n\t<key>start_on_login</key>\n\t<integer>0</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/loginitems.py",
    "content": "\"\"\"Utilities for working with System Preferences > Users & Groups > Login Items on macOS.\"\"\"\n\nfrom typing import List\n\nimport applescript\n\n__all__ = [\"add_login_item\", \"list_login_items\", \"remove_login_item\"]\n\n# The following functions are used to manipulate the Login Items list in System Preferences\n# To use these, your app must include the com.apple.security.automation.apple-events entitlement\n# in its entitlements file during signing and must have the NSAppleEventsUsageDescription key in\n# its Info.plist file\n# These functions use AppleScript to interact with System Preferences. I know of no other way to\n# do this programmatically from Python.  If you know of a better way, please let me know!\n\n\ndef add_login_item(app_name: str, app_path: str, hidden: bool = False):\n    \"\"\"Add app to login items\"\"\"\n    scpt = (\n        'tell application \"System Events\" to make login item at end with properties '\n        + f'{{name:\"{app_name}\", path:\"{app_path}\", hidden:{\"true\" if hidden else \"false\"}}}'\n    )\n    applescript.AppleScript(scpt).run()\n\n\ndef remove_login_item(app_name: str):\n    \"\"\"Remove app from login items\"\"\"\n    scpt = f'tell application \"System Events\" to delete login item \"{app_name}\"'\n    applescript.AppleScript(scpt).run()\n\n\ndef list_login_items() -> List[str]:\n    \"\"\"Return list of login items\"\"\"\n    scpt = 'tell application \"System Events\" to get the name of every login item'\n    return applescript.AppleScript(scpt).run()\n"
  },
  {
    "path": "tests/pasteboard.py",
    "content": "\"\"\"macOS Pasteboard/Clipboard access using native APIs\n\nAuthor: Rhet Turnbull <rturnbull+git@gmail.com>\n\nLicense: MIT License, copyright 2022 Rhet Turnbull\n\nOriginal Source: https://github.com/RhetTbull/textinator\n\nVersion: 1.1.0, 2022-10-26\n\"\"\"\n\nimport os\nimport typing as t\n\nfrom AppKit import (\n    NSPasteboard,\n    NSPasteboardTypePNG,\n    NSPasteboardTypeString,\n    NSPasteboardTypeTIFF,\n)\nfrom Foundation import NSData\n\n# shortcuts for types\nPNG = \"PNG\"\nTIFF = \"TIFF\"\n\n__all__ = [\"Pasteboard\", \"PasteboardTypeError\", \"PNG\", \"TIFF\"]\n\n\nclass PasteboardError(Exception):\n    \"\"\"Base class for Pasteboard exceptions\"\"\"\n\n    ...\n\n\nclass PasteboardTypeError(PasteboardError):\n    \"\"\"Invalid type specified\"\"\"\n\n    ...\n\n\nclass Pasteboard:\n    \"\"\"macOS Pasteboard/Clipboard Class\"\"\"\n\n    def __init__(self):\n        self.pasteboard = NSPasteboard.generalPasteboard()\n        self._change_count = self.pasteboard.changeCount()\n\n    def copy(self, text):\n        \"\"\"Copy text to clipboard\n\n        Args:\n            text (str): Text to copy to clipboard\n        \"\"\"\n        self.set_text(text)\n\n    def paste(self):\n        \"\"\"Retrieve text from clipboard\n\n        Returns: str\n        \"\"\"\n        return self.get_text()\n\n    def append(self, text: str):\n        \"\"\"Append text to clipboard\n\n        Args:\n            text (str): Text to append to clipboard\n        \"\"\"\n        new_text = self.get_text() + text\n        self.set_text(new_text)\n\n    def clear(self):\n        \"\"\"Clear clipboard\"\"\"\n        self.pasteboard.clearContents()\n        self._change_count = self.pasteboard.changeCount()\n\n    def copy_image(self, filename: t.Union[str, os.PathLike], format: str):\n        \"\"\"Copy image to clipboard from filename\n\n        Args:\n            filename (os.PathLike): Filename of image to copy to clipboard\n            format (str): Format of image to copy, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        self.set_image(filename, format)\n\n    def paste_image(\n        self,\n        filename: t.Union[str, os.PathLike],\n        format: str,\n        overwrite: bool = False,\n    ):\n        \"\"\"Paste image from clipboard to filename in PNG format\n\n        Args:\n            filename (os.PathLike): Filename of image to paste to\n            format (str): Format of image to paste, \"PNG\" or \"TIFF\"\n            overwrite (bool): Overwrite existing file\n\n        Raises:\n            FileExistsError: If file exists and overwrite is False\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        self.get_image(filename, format, overwrite)\n\n    def set_text(self, text: str):\n        \"\"\"Set text on clipboard\n\n        Args:\n            text (str): Text to set on clipboard\n        \"\"\"\n        self.pasteboard.clearContents()\n        self.pasteboard.setString_forType_(text, NSPasteboardTypeString)\n        self._change_count = self.pasteboard.changeCount()\n\n    def get_text(self) -> str:\n        \"\"\"Return text from clipboard\n\n        Returns: str\n        \"\"\"\n        return self.pasteboard.stringForType_(NSPasteboardTypeString) or \"\"\n\n    def get_image(\n        self,\n        filename: t.Union[str, os.PathLike],\n        format: str,\n        overwrite: bool = False,\n    ):\n        \"\"\"Save image from clipboard to filename in PNG format\n\n        Args:\n            filename (os.PathLike): Filename of image to save to\n            format (str): Format of image to save, \"PNG\" or \"TIFF\"\n            overwrite (bool): Overwrite existing file\n\n        Raises:\n            FileExistsError: If file exists and overwrite is False\n            PasteboardTypeError: If format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        if not isinstance(filename, str):\n            filename = str(filename)\n\n        if not overwrite and os.path.exists(filename):\n            raise FileExistsError(f\"File '{filename}' already exists\")\n\n        data = self.get_image_data(format)\n        data.writeToFile_atomically_(filename, True)\n\n    def set_image(self, filename: t.Union[str, os.PathLike], format: str):\n        \"\"\"Set image on clipboard from file in either PNG or TIFF format\n\n        Args:\n            filename (os.PathLike): Filename of image to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        data = NSData.dataWithContentsOfFile_(filename)\n        self.set_image_data(data, format)\n\n    def get_image_data(self, format: str) -> NSData:\n        \"\"\"Return image data from clipboard as NSData in PNG or TIFF format\n\n        Args:\n            format (str): Format of image to return, \"PNG\" or \"TIFF\"\n\n        Returns: NSData of image in PNG or TIFF format\n\n        Raises:\n            PasteboardTypeError if clipboard does not contain image in the specified type or type is invalid\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        pb_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF\n        if pb_type == NSPasteboardTypePNG and not self._has_png():\n            raise PasteboardTypeError(\"Clipboard does not contain PNG image\")\n        return self.pasteboard.dataForType_(pb_type)\n\n    def set_image_data(self, image_data: NSData, format: str):\n        \"\"\"Set image data on clipboard from NSData in a supported image format\n\n        Args:\n            image_data (NSData): Image data to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n\n        Raises: PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format not in (PNG, TIFF):\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n        format_type = NSPasteboardTypePNG if format == PNG else NSPasteboardTypeTIFF\n        self.pasteboard.clearContents()\n        self.pasteboard.setData_forType_(image_data, format_type)\n        self._change_count = self.pasteboard.changeCount()\n\n    def set_text_and_image(\n        self, text: str, filename: t.Union[str, os.PathLike], format: str\n    ):\n        \"\"\"Set both text from str and image from file in either PNG or TIFF format\n\n        Args:\n            text (str): Text to set on clipboard\n            filename (os.PathLike): Filename of image to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n        \"\"\"\n        if not isinstance(filename, str):\n            filename = str(filename)\n        data = NSData.dataWithContentsOfFile_(filename)\n        self.set_text_and_image_data(text, data, format)\n\n    def set_text_and_image_data(self, text: str, image_data: NSData, format: str):\n        \"\"\"Set both text and image data on clipboard from NSData in a supported image format\n\n        Args:\n            text (str): Text to set on clipboard\n            image_data (NSData): Image data to set on clipboard\n            format (str): Format of image to set, \"PNG\" or \"TIFF\"\n\n        Raises: PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        self.set_image_data(image_data, format)\n        self.pasteboard.setString_forType_(text, NSPasteboardTypeString)\n        self._change_count = self.pasteboard.changeCount()\n\n    def has_changed(self) -> bool:\n        \"\"\"Return True if clipboard has been changed by another process since last check\n\n        Returns: bool\n        \"\"\"\n        if self.pasteboard.changeCount() != self._change_count:\n            self._change_count = self.pasteboard.changeCount()\n            return True\n        return False\n\n    def has_image(self, format: t.Optional[str] = None) -> bool:\n        \"\"\"Return True if clipboard has image otherwise False\n\n        Args:\n            format (str): Format of image to check for, \"PNG\" or \"TIFF\" or None to check for any image\n\n        Returns:\n            True if clipboard has image otherwise False\n\n        Raises:\n            PasteboardTypeError if format is not \"PNG\" or \"TIFF\"\n        \"\"\"\n        if format is None:\n            return self.pasteboard.types().containsObject_(\n                NSPasteboardTypeTIFF\n            ) or self.pasteboard.types().containsObject_(NSPasteboardTypePNG)\n        elif format == PNG:\n            return self._has_png()\n        elif format == TIFF:\n            return self._has_tiff()\n        else:\n            raise PasteboardTypeError(\"Invalid format, must be PNG or TIFF\")\n\n    def has_text(self) -> bool:\n        \"\"\"Return True if clipboard has text, otherwise False\n\n        Returns: bool\n        \"\"\"\n        return self.pasteboard.types().containsObject_(NSPasteboardTypeString)\n\n    def _has_png(self) -> bool:\n        \"\"\"Return True if clipboard can paste PNG image otherwise False\n\n        Returns: bool\n        \"\"\"\n        return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypePNG]))\n\n    def _has_tiff(self) -> bool:\n        \"\"\"Return True if clipboard can paste TIFF image otherwise False\n\n        Returns: bool\n        \"\"\"\n        return bool(self.pasteboard.availableTypeFromArray_([NSPasteboardTypeTIFF]))\n"
  },
  {
    "path": "tests/test_textinator.py",
    "content": "\"\"\"Tests for Textinator\"\"\"\n\nimport os\nfrom time import sleep\n\nfrom .conftest import (\n    click_menu_item,\n    click_window_button,\n    copy_to_desktop,\n    log_file,\n    mark_screenshot,\n    process_is_running,\n)\nfrom .loginitems import list_login_items, remove_login_item\n\nTEST_FILE_HELLO_WORLD = \"tests/data/hello_world.png\"\nTEST_FILE_HELLO_WORLD_LINEBREAK = \"tests/data/hello_world_linebreaks.png\"\nTEST_FILE_HELLO = \"tests/data/hello.png\"\nTEST_FILE_WORLD = \"tests/data/world.png\"\nTEST_QRCODE = \"tests/data/qrcode.png\"\nTEST_QRCODE_WITH_TEXT = \"tests/data/qrcode_with_text.png\"\n\n\ndef test_screenshot_basic(pb):\n    \"\"\"Test screenshot detection\"\"\"\n    pb.clear()\n    with log_file() as log:\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello World\"\n        assert \"notification: Processed Screenshot\" in log.read()\n\n\ndef test_screenshot_linebreak(pb):\n    \"\"\"Test screenshot detection with linebreaks\"\"\"\n    pb.clear()\n    with log_file() as log:\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD_LINEBREAK) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello\\nWorld\"\n        assert \"notification: Processed Screenshot\" in log.read()\n\n\ndef test_screenshot_no_notification(pb):\n    \"\"\"Test screenshot detection with no notification\"\"\"\n    assert click_menu_item(\"Notification\")\n    pb.clear()\n    with log_file() as log:\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello World\"\n        assert \"notification:\" not in log.read()\n    # turn notification back on\n    assert click_menu_item(\"Notification\")\n\n\ndef test_screenshot_append(pb):\n    \"\"\"Test screenshot detection with append\"\"\"\n    assert click_menu_item(\"Append to Clipboard\")\n    pb.clear()\n    with copy_to_desktop(TEST_FILE_HELLO) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        with copy_to_desktop(TEST_FILE_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello\\nWorld\"\n    # turn append off\n    assert click_menu_item(\"Append to Clipboard\")\n\n\ndef test_screenshot_qrcode(pb):\n    \"\"\"Test screenshot detection with QR code\"\"\"\n    assert click_menu_item(\"Detect QR Codes\")\n    # set confidence to high because sometimes the QR code is detected as text\n    assert click_menu_item(\"Text Detection Confidence Threshold\", \"High\")\n    pb.clear()\n    with copy_to_desktop(TEST_QRCODE) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        assert pb.get_text() == \"https://github.com/RhetTbull/textinator\"\n    assert click_menu_item(\"Detect QR Codes\")\n    assert click_menu_item(\"Text Detection Confidence Threshold\", \"Low\")\n\n\ndef test_screenshot_qrcode_with_text(pb):\n    \"\"\"Test screenshot detection with QR code and text\"\"\"\n    assert click_menu_item(\"Detect QR Codes\")\n    pb.clear()\n    with copy_to_desktop(TEST_QRCODE_WITH_TEXT) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        text = pb.get_text()\n        assert \"https://github.com/RhetTbull/textinator\" in text\n        assert \"SCAN ME\" in text\n    assert click_menu_item(\"Detect QR Codes\")\n\n\ndef test_screenshot_qrcode_with_text_no_detect(pb):\n    \"\"\"Test screenshot detection with QR code and text when QR code detection is off\"\"\"\n    pb.clear()\n    with copy_to_desktop(TEST_QRCODE_WITH_TEXT) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        text = pb.get_text()\n        assert \"https://github.com/RhetTbull/textinator\" not in text\n        assert \"SCAN ME\" in text\n\n\ndef test_pause(pb):\n    \"\"\"Test pause\"\"\"\n    pb.clear()\n    pb.set_text(\"Paused\")\n    assert click_menu_item(\"Pause Text Detection\")\n    with log_file() as log:\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Paused\"\n        assert \"skipping screenshot because app is paused:\" in log.read()\n    with log_file() as log:\n        assert click_menu_item(\"Resume text detection\")\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello World\"\n        assert \"notification: Processed Screenshot\" in log.read()\n\n\ndef test_confidence(pb):\n    \"\"\"Test text detection confidence menu\"\"\"\n    pb.clear()\n    with log_file() as log:\n        assert click_menu_item(\"Text Detection Confidence Threshold\", \"Medium\")\n        assert \"'confidence': 'MEDIUM'\" in log.read()\n        with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n            mark_screenshot(filepath)\n            sleep(5)\n            assert pb.get_text() == \"Hello World\"\n        assert click_menu_item(\"Text Detection Confidence Threshold\", \"Low\")\n        assert \"'confidence': 'LOW'\" in log.read()\n\n\ndef test_clipboard_basic(pb):\n    \"\"\"Test clipboard detection\"\"\"\n    pb.clear()\n    pb.set_image(TEST_FILE_HELLO_WORLD, \"PNG\")\n    sleep(5)\n    assert pb.get_text() == \"Hello World\"\n\n\ndef test_clipboard_text_and_image(pb):\n    \"\"\"Test clipboard detection when clipboard has text and image (#16)\"\"\"\n    pb.clear()\n    with log_file() as log:\n        pb.set_text_and_image(\"Alt Text\", TEST_FILE_HELLO_WORLD, \"PNG\")\n        sleep(5)\n        assert \"clipboard has text, skipping\" in log.read()\n        assert pb.get_text() == \"Alt Text\"\n\n\ndef test_clipboard_no_clipboard(pb):\n    \"\"\"Test clipboard detection does not run when \"Detect Text in Images on Clipboard\" is off\"\"\"\n    assert click_menu_item(\"Detect Text in Images on Clipboard\")\n    pb.clear()\n    pb.set_image(TEST_FILE_HELLO_WORLD, \"PNG\")\n    sleep(5)\n    assert pb.get_text() == \"\"\n    assert click_menu_item(\"Detect Text in Images on Clipboard\")\n\n\ndef test_clear_clipboard(pb):\n    \"\"\"Test Clear Clipboard menu item works\"\"\"\n    pb.set_text(\"Hello World\")\n    assert click_menu_item(\"Clear Clipboard\")\n    assert pb.get_text() == \"\"\n\n\ndef test_confirm_clipboard_changes_yes(pb):\n    \"\"\"Test Confirm Clipboard Changes menu item works when pressing Yes\"\"\"\n    pb.clear()\n    with log_file() as log:\n        assert click_menu_item(\"Confirm Clipboard Changes\")\n        assert \"'confirmation': 1\" in log.read()\n    with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        assert click_window_button(1, 2)  # button 1 is Yes\n        sleep(5)\n        assert pb.get_text() == \"Hello World\"\n    assert click_menu_item(\"Confirm Clipboard Changes\")\n\n\ndef test_confirm_clipboard_changes_no(pb):\n    \"\"\"Test Confirm Clipboard Changes menu item works when pressing No\"\"\"\n    pb.set_text(\"Nope\")\n    with log_file() as log:\n        assert click_menu_item(\"Confirm Clipboard Changes\")\n        assert \"'confirmation': 1\" in log.read()\n    with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n        assert click_window_button(1, 1)  # button 2 is \"No\"\n        sleep(5)\n        assert pb.get_text() == \"Nope\"\n    assert click_menu_item(\"Confirm Clipboard Changes\")\n\n\n\ndef test_show_last_text_detection(pb):\n    \"\"\"Test Show Last Text Detection menu item works\"\"\"\n    pb.clear()\n    with copy_to_desktop(TEST_FILE_HELLO_WORLD) as filepath:\n        mark_screenshot(filepath)\n        sleep(5)\n    with log_file() as log:\n        assert click_menu_item(\"Show Last Text Detection\")\n        assert \"Showing confirmation window\" in log.read()\n        assert click_window_button(1, 2)  # button 1 is Yes\n        sleep(5)\n        assert pb.get_text() == \"Hello World\"\n    assert click_menu_item(\"Confirm Clipboard Changes\")\n\n\ndef test_enable_start_on_login():\n    \"\"\"Test Start Textinator on Login menu item works\"\"\"\n    # setup_teardown() should have removed the login item if it existed\n    assert \"Textinator\" not in list_login_items()\n    assert click_menu_item(\"Start Textinator on Login\")\n    assert \"Textinator\" in list_login_items()\n    assert click_menu_item(\"Start Textinator on Login\")\n    assert \"Textinator\" not in list_login_items()\n\n\ndef test_about():\n    \"\"\"Test About dialog\"\"\"\n    assert click_menu_item(\"About Textinator\")\n    assert click_window_button(1, 1)\n\n\ndef test_quit():\n    \"\"\"Test Quit menu item\"\"\"\n    assert process_is_running(\"Textinator\")\n    assert click_menu_item(\"Quit Textinator\")\n    assert not process_is_running(\"Textinator\")\n    os.system(\"open -a Textinator\")\n"
  }
]