├── .github └── workflows │ ├── buildapp.yml │ ├── on_release.yml │ ├── screenshot.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── WhatdidLauncher ├── AppDelegate.swift ├── Info.plist ├── MainLauncher.xib ├── WhatdidLauncher.entitlements └── WhatdidLauncherRelease.entitlements ├── buildscripts ├── archive-export.plist ├── create_infoplist_preprocessor.sh ├── dmgbuild_settings.py ├── infoplist-preprocess │ ├── .gitignore │ ├── README.md │ └── fixed │ │ └── sparkle.h └── update_appcast ├── how_to_release.md ├── other └── whatdid-logo.svg ├── whatdid.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── whatdid-app-store.xcscheme │ ├── whatdid-debug.xcscheme │ ├── whatdid-release.xcscheme │ └── whatdid-ui-test.xcscheme ├── whatdid ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── logo-1024.png │ │ ├── logo-128.png │ │ ├── logo-16.png │ │ ├── logo-256.png │ │ ├── logo-32.png │ │ ├── logo-512.png │ │ └── logo-64.png │ ├── Contents.json │ ├── GitHub.imageset │ │ ├── Contents.json │ │ ├── GitHub-Mark-32px-1.png │ │ ├── GitHub-Mark-32px.png │ │ └── GitHub-Mark-Light-32px.png │ ├── Help.imageset │ │ ├── Contents.json │ │ ├── help-128.png │ │ ├── help-32.png │ │ └── help-64.png │ ├── MenuIcon.imageset │ │ ├── Contents.json │ │ ├── logo-19.png │ │ └── logo-40.png │ └── globey.imageset │ │ ├── Contents.json │ │ ├── globe-16.png │ │ ├── globe-32.png │ │ └── globe-64.png ├── Info.plist ├── controllers │ ├── CloseConfirmer.swift │ ├── ConfirmViewController.swift │ ├── ConfirmViewController.xib │ ├── DayEndReportController.swift │ ├── DayEndReportController.xib │ ├── DayStartController.swift │ ├── DayStartController.xib │ ├── LargeReportController.swift │ ├── LargeReportController.xib │ ├── LargeReportController │ │ ├── EditEntriesController.swift │ │ ├── EditEntriesDataSource.swift │ │ ├── EntriesTreeController.swift │ │ ├── EntriesTreeDataSource.swift │ │ └── Sorting.swift │ ├── NonFocusingNSViewController.swift │ ├── PtnViewController.swift │ ├── PtnViewController.xib │ ├── TutorialViewController.swift │ ├── TutorialViewController.xib │ └── prefs │ │ ├── PrefsAboutPaneController.swift │ │ ├── PrefsAboutPaneController.xib │ │ ├── PrefsFeedbackPaneController.swift │ │ ├── PrefsFeedbackPaneController.xib │ │ ├── PrefsGeneralPaneController.swift │ │ ├── PrefsGeneralPaneController.xib │ │ ├── PrefsViewController.swift │ │ └── PrefsViewController.xib ├── extensions │ ├── Array+Helpers.swift │ ├── Date+Helpers.swift │ ├── Int+Helpers.swift │ ├── NSApperance+Helpers.swift │ ├── NSButton+Helpers.swift │ ├── NSControl+Helper.swift │ ├── NSRange+Helpers.swift │ ├── NSRecursiveLock+Helpers.swift │ ├── NSStackView+Helpers.swift │ ├── NSTableView+Helpers.swift │ ├── NSTextField+Helpers.swift │ ├── NSView+Helpers.swift │ ├── NSViewController+Helper.swift │ ├── OSLogType+Helpers.swift │ ├── OutputStream+Helpers.swift │ ├── String+Helpers.swift │ ├── TimeZone+Helpers.swift │ ├── UInt32+Helpers.swift │ └── UUID+Helpers.swift ├── main │ ├── AppDelegate.swift │ ├── AutoCompletingField.swift │ ├── Base.lproj │ │ └── MainMenu.xib │ ├── GlobalShortcut.swift │ └── MainMenu.swift ├── model │ ├── Entry+CoreDataClass.swift │ ├── Entry+CoreDataProperties.swift │ ├── EntryExportFormats.swift │ ├── FlatEntry+Serde.swift │ ├── FlatEntry.swift │ ├── Goal+CoreDataClass.swift │ ├── Goal+CoreDataProperties.swift │ ├── Model.swift │ ├── Model.xcdatamodeld │ │ └── Model.xcdatamodel │ │ │ └── contents │ ├── Project+CoreDataClass.swift │ ├── Project+CoreDataProperties.swift │ ├── Session+CoreDataClass.swift │ ├── Session+CoreDataProperties.swift │ ├── Task+CoreDataClass.swift │ ├── Task+CoreDataProperties.swift │ └── UsageDatumDTO.swift ├── scheduling │ ├── DefaultScheduler.swift │ ├── DelegatingScheduler.swift │ ├── ManualTickScheduler.swift │ ├── OpenCloseHelper.swift │ ├── OpenReason.swift │ ├── Scheduler.swift │ ├── SystemClockScheduler.swift │ └── TimeUtil.swift ├── ui_testhook │ ├── DateSetterView.swift │ ├── PasteboardView.swift │ ├── SampleData.swift │ ├── UITestCommonConsts.swift │ ├── UiTestWindow.swift │ ├── UiTestWindow.xib │ ├── WhatdidControlHooks.swift │ └── screenshot-entries.txt ├── util │ ├── AnimationHelper.swift │ ├── Atomic.swift │ ├── DiagonalBoxFillHelper.swift │ ├── Logger.swift │ ├── Prefs.swift │ ├── PushableString.swift │ ├── ScrollBarHelper.swift │ ├── SimpleRandom.swift │ ├── SortedMap.swift │ ├── StartupMessage.swift │ ├── SubsequenceMatcher.swift │ ├── TypeAliases.swift │ ├── UpdateChannel.swift │ ├── Version.swift │ └── usagetracking │ │ ├── UsageAction.swift │ │ ├── UsageTracking.swift │ │ └── UsageTrackingJsonDatum.swift ├── views │ ├── ButtonWithClosure.swift │ ├── ControllerDisplayView.swift │ ├── DateRangePane.swift │ ├── DateRangePicker.swift │ ├── DisclosureWithLabel.swift │ ├── ExpandableTextField.swift │ ├── FlippedView.swift │ ├── GoalsView.swift │ ├── HrefButton.swift │ ├── ProjectTaskFinder.swift │ ├── SegmentedTimelineView.swift │ ├── TextFieldWithPopup.swift │ ├── TextOptionsList.swift │ ├── WdView.swift │ └── WhatdidTextField.swift ├── whatdid.entitlements ├── whatdidAppStore.entitlements └── whatdidRelease.entitlements ├── whatdidTests ├── Info.plist ├── controllers │ ├── ControllerTestBase.swift │ ├── LargeReportController │ │ └── EntriesTreeControllerTest.swift │ ├── PrefsGeneralPaneControllerTest.swift │ └── PtnViewControllerTest.swift ├── extensions │ ├── NSRange+HelpersTest.swift │ └── Unit32+HelpersTest.swift ├── model │ ├── EntryExportFormatsTest.swift │ └── ModelTest.swift ├── scheduling │ ├── DelegatingSchedulerTest.swift │ ├── DummyScheduler.swift │ ├── OpenCloseHelperTest.swift │ ├── SystemClockSchedulerTest.swift │ └── TimeUtilTest.swift ├── test_helpers │ ├── ExtraAssertions.swift │ └── NSEventHelpers.swift ├── util │ ├── PrefsTest.swift │ ├── SortedMapTest.swift │ └── SubsequenceMatcherTest.swift └── views │ ├── SegmentedTimelineViewTest.swift │ └── TextOptionsListTest.swift └── whatdidUITests ├── Info.plist ├── UITestBase.swift ├── app ├── AppUITestBase.swift ├── DailyReportTest.swift ├── GoalsTest.swift ├── LongSessionPromptTest.swift ├── PrefsUITest.swift ├── PtnViewControllerTest.swift ├── ScheduleTest.swift ├── ScreenshotGenerator.swift └── TutorialTest.swift ├── component ├── ComponentUITests.swift └── DatePickerUtils.swift ├── extensions ├── ExtraStringHelpers.swift ├── ExtraStringHelpersTest.swift ├── XCTestCase+Helpers.swift ├── XCUIElement+Helpers.swift ├── XCUIElementQuery+Helpers.swift └── XCUIKeyboardKey+Helpers.swift ├── model └── FlatEntry+SerdeTest.swift ├── util └── AutocompleteFieldHelper.swift └── whatdidUITests.swift /.github/workflows/on_release.yml: -------------------------------------------------------------------------------- 1 | name: on release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | release_ref: 9 | description: "The git ref for a release, like \"refs/tags/v1.2.3\"" 10 | required: true 11 | clobber_upload: 12 | description: "If set to \"yes\", will add --clobber to the artifact upload." 13 | 14 | jobs: 15 | create-appcast-pr: 16 | runs-on: macos-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | ref: gh-pages 21 | - name: run buildscripts/create_appcast_pr 22 | run: | 23 | set -e 24 | if [[ "$SHOULD_CLOBBER" == yes ]]; then 25 | export CLOBBER_RELEASE_UPLOAD=1 26 | echo '::warning title=clobber_upload::Will clobber release artifact with new upload.' 27 | elif [[ -n "$SHOULD_CLOBBER" ]]; then 28 | echo "::error title=clobber_upload::clobber_upload is \"$SHOULD_CLOBBER\". Expected \"yes\" or nothing." 29 | exit 1 30 | fi 31 | if [[ -n "$GITHUB_REF_INPUT" ]]; then 32 | export GITHUB_REF="$GITHUB_REF_INPUT" 33 | fi 34 | ./buildscripts/create_appcast_pr "$GITHUB_REF" 35 | env: 36 | GITHUB_REF_INPUT: ${{ github.event.inputs.release_ref }} 37 | APPCAST_PRIVATE_KEY: ${{ secrets.APPCAST_PRIVATE_KEY }} 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | SHOULD_CLOBBER: ${{ github.event.inputs.clobber_upload }} 40 | UPDATE_MAIN_DOWNLOAD: ${{ secrets.APPLE_ID }} 41 | -------------------------------------------------------------------------------- /.github/workflows/screenshot.yml: -------------------------------------------------------------------------------- 1 | name: Screenshot 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: {} 7 | 8 | jobs: 9 | screenshot: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Dismiss upgrade dialog if present 14 | run: | 15 | for opt in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates; do 16 | sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate "$opt" -boolean FALSE 17 | done 18 | - name: Set dark mode 19 | if: false 20 | run: | 21 | sudo osascript -e 'tell app "System Events" to tell appearance preferences to set dark mode to true' 22 | - name: Get screen resolution 23 | run: | 24 | printf '::notice title=resolution::%s\n' "$(system_profiler SPDisplaysDataType | grep Resolution | sed 's/.*: *//')" 25 | 26 | - name: Install xcparse 27 | run: brew install chargepoint/xcparse/xcparse 28 | - name: Save current time zone 29 | # Save the current time zone. 30 | # systemsetup returns e.g. "Time Zone: US/New_York", and we just 31 | # want the "US/New_York" bit. 32 | id: initial 33 | run: 'echo "::set-output name=timezone::$(sudo systemsetup -gettimezone | cut -d " " -f 3)"' 34 | - name: Set time zone 35 | run: | 36 | # Find a time zone such that it's 6 or 7pm. 37 | # (tail -n +2 drops the first line, which is a header) 38 | for tz in $(sudo systemsetup -listtimezones | tail -n +2); do 39 | sudo systemsetup -settimezone "$tz" 40 | now_hr="$(date +%H)" 41 | if [[ $now_hr -ge 18 && $now_hr -le 19 ]]; then 42 | break 43 | fi 44 | done 45 | - name: Build and run screenshot generator 46 | run: xcodebuild clean test -scheme whatdid-ui-test -only-testing whatdidUITests/ScreenshotGenerator -resultBundlePath ${{ runner.temp }}/test-results/bundle 47 | - name: Restore time zone 48 | if: ${{ always() }} 49 | run: | 50 | sudo systemsetup -settimezone "${{ steps.initial.outputs.timezone }}" 51 | - name: Gather screenshots 52 | if: ${{ always() }} 53 | run: | 54 | mkdir -p "${{ runner.temp }}/screenshots/all" 55 | mkdir -p "${{ runner.temp }}/screenshots/export/screenshots-${{ github.run_number }}" 56 | # export the screenshots, organized by test 57 | xcparse screenshots --test '${{ runner.temp }}/test-results/bundle' '${{ runner.temp }}/screenshots/all' 58 | for screenshot_file in $(find '${{ runner.temp }}/screenshots/all/ScreenshotGenerator' -name '*.png' -or -name '*.jpg'); do 59 | # screenshot_file will be something like: 60 | # .../screenshots/ScreenshotGenerator/testDailyReport()/daily-report_1_1A98890B-2184-400D-97D2-A5B51E3B25FB.png 61 | # We want to turn that into t just "daily-report.png" 62 | short_name="$(basename "$screenshot_file" | sed 's/_.*\.png$/.png/')" 63 | if [[ -f "${{ runner.temp }}/screenshots/export/$short_name" ]]; then 64 | # This guards against a bug where multiple actions write to the same simple name. 65 | # In that case, just use the original, uuid'd name. 66 | short_name="$(basename "$screenshot_file")" 67 | fi 68 | mv "$screenshot_file" "${{ runner.temp }}/screenshots/export/screenshots-${{ github.run_number }}/$short_name" 69 | done 70 | - name: Upload screenshots 71 | if: ${{ always() }} 72 | uses: actions/upload-artifact@v2 73 | with: 74 | name: screenshots-${{ github.run_number }} 75 | path: ${{ runner.temp }}/screenshots/export 76 | - name: Gather logs (if failed) 77 | if: ${{ failure() }} 78 | run: | 79 | mkdir "${{ runner.temp }}/export" 80 | mv "$(readlink ${{ runner.temp }}/test-results/bundle)" "${{ runner.temp }}/export/whatdid-uitest-failures-${{ github.run_number }}.xcresult" 81 | - name: Upload test artifacts (if failed) 82 | if: ${{ failure() }} 83 | uses: actions/upload-artifact@v2 84 | with: 85 | name: whatdid-ui-tests-${{ github.run_number }} 86 | path: ${{ runner.temp }}/export 87 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | inputs: 10 | runUiTests: 11 | description: Run UI tests 12 | required: false 13 | type: boolean 14 | 15 | jobs: 16 | ui-tests: 17 | if: ${{ github.event.inputs.runUiTests }} # see #5495d097 18 | runs-on: macos-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Dismiss upgrade dialog if present 22 | run: | 23 | update_name=$(softwareupdate -l | grep "Title: " | awk -F[:,] '{print $2}' | awk '{$1=$1};1') 24 | if [ ! -z "$update_name" ]; then 25 | sudo softwareupdate --ignore "$update_name" 26 | fi 27 | - name: Get current time 28 | id: start_time 29 | run: 'echo "::set-output name=unixtime::@$(date +%s)"' 30 | - name: Build and run UI tests 31 | run: xcodebuild clean test -scheme whatdid-ui-test -resultBundlePath ${{ runner.temp }}/test-results/bundle 32 | - name: Gather logs 33 | if: ${{ failure() }} 34 | run: | 35 | mkdir "${{ runner.temp }}/export" 36 | mv "$(readlink ${{ runner.temp }}/test-results/bundle)" "${{ runner.temp }}/export/whatdid-uitest-failures-${{ github.run_number }}.xcresult" 37 | - name: Upload test artifacts 38 | if: ${{ failure() }} 39 | uses: actions/upload-artifact@v2 40 | with: 41 | name: whatdid-ui-tests-${{ github.run_number }} 42 | path: ${{ runner.temp }}/export 43 | unit-tests: 44 | runs-on: macos-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - name: Build and run unit tests 48 | run: xcodebuild clean test -scheme whatdid-debug | tee build.stdout 49 | - name: Report warnings and errors 50 | run: | 51 | # Check for errors, warnings, and TODOs. 52 | # 53 | # Each line will be something like: 54 | # /path/to/whatdid/whatdid/main/SomeFile.swift:1234:56: warning: todo hello 55 | # 56 | # The output format for GH actions is: 57 | # ::error file={name},line={line},endLine={endLine},title={title}::{message} 58 | set -euo pipefail 59 | { grep -i '^/.*: \(warning\|error\): ' build.stdout || true; } | sort -u > warnings.txt 60 | exit_code=0 61 | while read -r line; do 62 | IFS=: read filename fileline filecol severity message <<< "$line" 63 | severity="$(echo "$severity" | sed 's/^ *//')" 64 | message="$(echo "$message" | sed 's/^ *//')" 65 | title="Compiler $severity" 66 | if grep -qi '^todo ' <<< "$message" ; then 67 | severity=error 68 | title="TODO detected" 69 | message="$(echo "$message" | sed 's/^todo //i')" 70 | fi 71 | printf '::%s file=%s,line=%s,col=%s,title=%s::%s\n' "$severity" "$filename" "$fileline" "$filecol" "$title" "$message" 72 | if [[ "$severity" == error ]]; then 73 | exit_code=1 74 | fi 75 | done < warnings.txt 76 | exit $exit_code 77 | 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | /build 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yuval Shavit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | whatdid? 2 | ======= 3 | 4 | Have you ever ended a day and wondered where the time went? 5 | 6 | "***What*** ***d***id ***I*** ***d***o all day?!" 7 | 8 | This project is meant to help you answer that question. 9 | -------------------------------------------------------------------------------- /WhatdidLauncher/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Taken from https://theswiftdev.com/how-to-launch-a-macos-app-at-login/ 2 | 3 | import Cocoa 4 | import os 5 | 6 | extension Notification.Name { 7 | static let killLauncher = Notification.Name("killLauncher") 8 | } 9 | 10 | @NSApplicationMain 11 | class AppDelegate: NSObject { 12 | 13 | @objc func terminate() { 14 | NSApp.terminate(nil) 15 | } 16 | } 17 | 18 | extension AppDelegate: NSApplicationDelegate { 19 | 20 | func applicationDidFinishLaunching(_ aNotification: Notification) { 21 | let mainAppIdentifier = "com.yuvalshavit.whatdid" 22 | let runningApps = NSWorkspace.shared.runningApplications 23 | let isRunning = !runningApps.filter { $0.bundleIdentifier == mainAppIdentifier }.isEmpty 24 | 25 | if isRunning { 26 | os_log(.info, "whatdid launcher: whatdid is already running") 27 | self.terminate() 28 | } else { 29 | os_log(.info, "whatdid launcher: will try to launch whatdid") 30 | DistributedNotificationCenter.default().addObserver(self, selector: #selector(self.terminate), name: .killLauncher, object: mainAppIdentifier) 31 | 32 | let path = Bundle.main.bundlePath as NSString 33 | var components = path.pathComponents 34 | components.removeLast() 35 | components.removeLast() 36 | components.removeLast() 37 | components.append("MacOS") 38 | components.append("Whatdid") //main app name 39 | 40 | let newPath = NSString.path(withComponents: components) 41 | os_log(.info, "whatdid launcher about to launch: %@", newPath) 42 | 43 | NSWorkspace.shared.launchApplication(newPath) 44 | } 45 | } 46 | 47 | func applicationWillTerminate(_ notification: Notification) { 48 | os_log(.info, "whatdid launcher is exiting") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /WhatdidLauncher/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.productivity 25 | LSBackgroundOnly 26 | 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSHumanReadableCopyright 30 | Copyright © 2020 - 2023 Yuval Shavit. All rights reserved. 31 | NSMainNibFile 32 | MainLauncher 33 | NSPrincipalClass 34 | NSApplication 35 | 36 | 37 | -------------------------------------------------------------------------------- /WhatdidLauncher/MainLauncher.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WhatdidLauncher/WhatdidLauncher.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /WhatdidLauncher/WhatdidLauncherRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /buildscripts/archive-export.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | destination 6 | export 7 | method 8 | developer-id 9 | signingStyle 10 | manual 11 | 12 | 13 | -------------------------------------------------------------------------------- /buildscripts/create_infoplist_preprocessor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -euo pipefail 2 | 3 | sha="$(git log -n 1 '--pretty=format:%h')" 4 | if [[ $(git status -s | wc -c) -ne 0 ]]; then 5 | sha="${sha}.dirty" 6 | fi 7 | 8 | preprocess_dir=infoplist-preprocess 9 | compiled_headers="$preprocess_dir/compiled.h" 10 | 11 | cat "$preprocess_dir/fixed/"*.h > "$compiled_headers" 12 | 13 | printf '#define FULL_BUILD_VERSION %s\n' "$(/bin/date -u '+%Y.%m%d.%H%M%S')" >> "$compiled_headers" 14 | -------------------------------------------------------------------------------- /buildscripts/infoplist-preprocess/.gitignore: -------------------------------------------------------------------------------- 1 | compiled.h 2 | -------------------------------------------------------------------------------- /buildscripts/infoplist-preprocess/README.md: -------------------------------------------------------------------------------- 1 | - fixed.h is checked in 2 | - compiled.h is generated from `fixed/*.h plus other stuff by `buildscripts/create_infoplist_preprocessor.sh` 3 | -------------------------------------------------------------------------------- /buildscripts/infoplist-preprocess/fixed/sparkle.h: -------------------------------------------------------------------------------- 1 | #define SPARKLE_FEED_URL "https://whatdid.yuvalshavit.com/appcast/appcast.xml" 2 | -------------------------------------------------------------------------------- /buildscripts/update_appcast: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | function msg() { 5 | echo >&2 "$@" 6 | } 7 | 8 | function bulleted() { 9 | bullet="${1:-• }" 10 | sed "s/^/ ${bullet}/" >&2 11 | } 12 | 13 | # Validate args 14 | my_name="$(basename "$0")" 15 | if [[ $# -ne 2 ]]; then 16 | msg "Usage: $my_name " 17 | msg "" 18 | msg "The zip file should contain a single file, Whatdid.dmg." 19 | msg "This will then update the appcast dir you provide." 20 | exit 1 21 | fi 22 | zip_path="$1" 23 | appcast_dir="$2" 24 | 25 | # Validate zip file 26 | zip_listing="$(zipinfo -1 "$zip_path" 2>/dev/null || echo '' | head -n 2)" 27 | if [[ -z "$zip_listing" ]]; then 28 | msg "Couldn't unzip file: $zip_path." 29 | msg "It does not appear to be a valid zip file." 30 | exit 1 31 | elif [[ "$zip_listing" != Whatdid.dmg ]] ; then 32 | msg "$zip_path must contain only a Whatdid.dmg. Instead, it contained:" 33 | zipinfo -1 "$zip_path" | bulleted 34 | exit 1 35 | fi 36 | 37 | # Validate destination 38 | if [[ ! -d "$appcast_dir" ]]; then 39 | msg "$appcast_dir is not a directory" 40 | exit 1 41 | fi 42 | ( 43 | cd "$appcast_dir" 44 | git_status="$(git status 2>/dev/null | tail -n 1 || echo NOT_GIT)" 45 | if [[ "$git_status" == NOT_GIT ]]; then 46 | msg "$appcast_dir must be in a git repo (or be its root)" 47 | exit 1 48 | elif [[ "$git_status" != 'nothing to commit, working tree clean' ]]; then 49 | msg "$appcast_dir has uncommited changes. Commit them before running this script." 50 | git status | bulleted ' ' 51 | exit 1 52 | fi 53 | ) 54 | 55 | # Find the generate_appcast script 56 | gen_appcast="$(find ~/Library/Developer/Xcode/DerivedData -path '*/SourcePackages/artifacts/Sparkle/bin/generate_appcast')" 57 | if [[ -z "$gen_appcast" ]]; then 58 | msg "Couldn't find generate_appcast" 59 | exit 1 60 | elif [[ "$(wc -l <<< "$gen_appcast")" -gt 1 ]]; then 61 | msg "Found too many candidates for generate_appcast:" 62 | echo "$gen_appcast" | bulleted 63 | exit 1 64 | fi 65 | 66 | # Get original appcast.xml 67 | appcast_xml_path="$appcast_dir/appcast.xml" 68 | original_appcast="$(cat "$appcast_xml_path" 2>/dev/null || echo)" 69 | 70 | # Extract Whatdid.dmg 71 | tmp_dir="$(mktemp -d)" 72 | tmp_name="Whatdid-appcast-tmp.$(uuidgen).dmg" 73 | msg "Unzipping Whatdid.dmg to $appcast_dir" 74 | unzip -d "$tmp_dir" "$zip_path" >/dev/null 75 | mv "$tmp_dir/Whatdid.dmg" "$appcast_dir/$tmp_name" 76 | rmdir "$tmp_dir" || msg "Couldn't rmdir $tmp_dir . Will proceed anyway." 77 | 78 | >&2 printf 'generate_appcast: ' 79 | >&2 "$gen_appcast" "$appcast_dir" 80 | 81 | new_appcast="$(cat "$appcast_xml_path" 2>/dev/null || echo)" 82 | 83 | new_entries="$(diff <(echo "$original_appcast") <(echo "$new_appcast") | grep '^>' | sed 's/> *//' || echo)" 84 | new_version="$(echo "$new_entries" | grep --fixed-strings '' | sed -E 's/.*>(.*)<.*/\1/' || echo)" 85 | if [[ -z "$new_version" ]]; then 86 | msg "This version of Whatdid is already present in the appcast." 87 | rm "$appcast_dir/$tmp_name" 88 | # The generate_appcast script will have updated the xml with the new version. Restore it. 89 | echo "$original_appcast" > "$appcast_xml_path" 90 | exit 1 91 | fi 92 | msg "Found new version: $new_version" 93 | 94 | # Rename the tmp file and tweak the xml 95 | new_name="Whatdid-${new_version}.dmg" 96 | mv "$appcast_dir/$tmp_name" "$appcast_dir/$new_name" 97 | py_script=""" 98 | import sys 99 | look_for=sys.argv[1] 100 | replace_with=sys.argv[2] 101 | for l in sys.stdin: 102 | l = l.rstrip('\n') 103 | print(l.replace(look_for, replace_with)) 104 | """ 105 | tmp_appcast_xml="$(mktemp)" 106 | python -c "$py_script" "$tmp_name" "$new_name" <"$appcast_xml_path" >"$tmp_appcast_xml" 107 | mv "$tmp_appcast_xml" "$appcast_xml_path" 108 | 109 | ( 110 | cd "$appcast_dir" 111 | new_branch="add-${new_version}" 112 | msg "Uploading new branch: $new_branch" 113 | git checkout -b "$new_branch" >/dev/null 114 | git add . >/dev/null 115 | git commit -am "adding ${new_version} to appcast.xml" >/dev/null 116 | git push -u origin "$new_branch" 117 | ) 118 | 119 | -------------------------------------------------------------------------------- /how_to_release.md: -------------------------------------------------------------------------------- 1 | 1. Publish a new release on GH. 2 | 1. Create a new tag 3 | 2. Mark the release as pre-release to flag this as an alpha release. 4 | (This affects whether all users will get it, or only those who have 5 | opted into alphas.) 6 | 2. Wait for the "on release" GH action to run. It'll be named after the 7 | release name from the previous step. 8 | 3. That GH action will create a PR. Check that: 9 | 1. ~The symlink at `Whatdid.dmg` has been updated~ (disabling this for now, because I'm not building notarized apps anymore, because I don't want to pay Apple) 10 | 2. The screenshots look good 11 | 3. There's a new release notes `.md` file for this release. 12 | 4. Some `.delta` files got generated 13 | 4. Merge the PR and wait for GH actions to update 14 | https://whatdid.yuvalshavit.com. 15 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "KeyboardShortcuts", 6 | "repositoryURL": "https://github.com/sindresorhus/KeyboardShortcuts", 7 | "state": { 8 | "branch": null, 9 | "revision": "9bc72c441c713362a137ecbb4104920655a9aa5e", 10 | "version": "1.4.0" 11 | } 12 | }, 13 | { 14 | "package": "Sparkle", 15 | "repositoryURL": "https://github.com/sparkle-project/Sparkle", 16 | "state": { 17 | "branch": "2.x", 18 | "revision": "9684a433ee0b55da607791c56f501975f5e11ae5", 19 | "version": null 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/xcshareddata/xcschemes/whatdid-app-store.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/xcshareddata/xcschemes/whatdid-release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 71 | 73 | 79 | 80 | 81 | 82 | 88 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /whatdid.xcodeproj/xcshareddata/xcschemes/whatdid-ui-test.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | 82 | 88 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo-16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "logo-32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "logo-32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "logo-64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "logo-128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "logo-256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "logo-256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "logo-512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "logo-512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "logo-1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-1024.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-128.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-16.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-256.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-32.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-512.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/AppIcon.appiconset/logo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/AppIcon.appiconset/logo-64.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/GitHub.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "GitHub-Mark-32px-1.png", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "GitHub-Mark-32px.png", 15 | "idiom" : "universal" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "GitHub-Mark-Light-32px.png", 25 | "idiom" : "universal" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | }, 32 | "properties" : { 33 | "template-rendering-intent" : "template" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-32px-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-32px-1.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-32px.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-Light-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/GitHub.imageset/GitHub-Mark-Light-32px.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/Help.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "help-32.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "help-64.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "help-128.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/Help.imageset/help-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/Help.imageset/help-128.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/Help.imageset/help-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/Help.imageset/help-32.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/Help.imageset/help-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/Help.imageset/help-64.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/MenuIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo-19.png", 5 | "idiom" : "mac", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "logo-40.png", 10 | "idiom" : "mac", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | }, 18 | "properties" : { 19 | "auto-scaling" : "auto", 20 | "template-rendering-intent" : "template" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/MenuIcon.imageset/logo-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/MenuIcon.imageset/logo-19.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/MenuIcon.imageset/logo-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/MenuIcon.imageset/logo-40.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/globey.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "globe-16.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "globe-32.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "globe-64.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/globey.imageset/globe-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/globey.imageset/globe-16.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/globey.imageset/globe-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/globey.imageset/globe-32.png -------------------------------------------------------------------------------- /whatdid/Assets.xcassets/globey.imageset/globe-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yshavit/whatdid/56769a3e2b763f02cf5ba1739ec245341579cf59/whatdid/Assets.xcassets/globey.imageset/globe-64.png -------------------------------------------------------------------------------- /whatdid/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleVersion 24 | FULL_BUILD_VERSION 25 | LSApplicationCategoryType 26 | public.app-category.productivity 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSHumanReadableCopyright 30 | Copyright © 2020 - 2023 Yuval Shavit. All rights reserved. 31 | NSMainNibFile 32 | MainMenu 33 | NSPrincipalClass 34 | NSApplication 35 | NSSupportsAutomaticTermination 36 | 37 | LSUIElement 38 | 39 | NSSupportsSuddenTermination 40 | 41 | SUEnableInstallerLauncherService 42 | 43 | SUFeedURL 44 | SPARKLE_FEED_URL 45 | SUPublicEDKey 46 | +c3D0qE8WwVHoLX6rl2fTqniphIs/5s4UPW4D7w+KxQ= 47 | 48 | 49 | -------------------------------------------------------------------------------- /whatdid/controllers/CloseConfirmer.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | protocol CloseConfirmer { 6 | func requestClose(on: NSWindow) -> Bool 7 | } 8 | -------------------------------------------------------------------------------- /whatdid/controllers/ConfirmViewController.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class ConfirmViewController: NSViewController { 6 | @IBOutlet weak private var headerField: NSTextField! 7 | @IBOutlet weak private var detailsField: NSTextField! 8 | @IBOutlet weak private var minHeightConstraint: NSLayoutConstraint! 9 | @IBOutlet weak private var widthConstraint: NSLayoutConstraint! 10 | @IBOutlet weak private var proceedButton: NSButton! 11 | @IBOutlet weak private var cancelButton: NSButton! 12 | 13 | var onProceed = {} 14 | 15 | var header: String { 16 | get { 17 | headerField.stringValue 18 | } 19 | set (value) { 20 | headerField.stringValue = value 21 | } 22 | } 23 | 24 | var detail: String { 25 | get { 26 | detailsField.stringValue 27 | } 28 | set (value) { 29 | detailsField.stringValue = value 30 | } 31 | } 32 | 33 | var proceedButtonText: String { 34 | get { 35 | proceedButton.title 36 | } 37 | set (value) { 38 | proceedButton.title = value 39 | } 40 | } 41 | 42 | var cancelButtonText: String { 43 | get { 44 | cancelButton.title 45 | } 46 | set (value) { 47 | cancelButton.title = value 48 | } 49 | } 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | } 54 | 55 | @objc private func handleButton(_ button: NSButton) { 56 | wdlog(.debug, "handling button: %{public}@", button.title) 57 | } 58 | 59 | @IBAction func handleProceed(_ sender: Any) { 60 | onProceed() 61 | endParentSheet(with: .OK) 62 | } 63 | 64 | @IBAction func handleCancel(_ sender: Any) { 65 | endParentSheet(with: .cancel) 66 | } 67 | 68 | private func endParentSheet(with response: NSApplication.ModalResponse) { 69 | if let myWindow = view.window, let mySheetParent = myWindow.sheetParent { 70 | mySheetParent.endSheet(myWindow, returnCode: response) 71 | } 72 | } 73 | 74 | func prepareToAttach(to window: NSWindow) -> Action { 75 | let confirmWindow = NSWindow(contentRect: window.frame, styleMask: [.titled], backing: .buffered, defer: true) 76 | confirmWindow.backgroundColor = NSColor.windowBackgroundColor 77 | confirmWindow.contentViewController = self 78 | confirmWindow.initialFirstResponder = cancelButton 79 | widthConstraint.constant = window.frame.width 80 | minHeightConstraint.constant = window.frame.height 81 | return { 82 | window.beginSheet(confirmWindow) 83 | confirmWindow.makeKeyAndOrderFront(self) 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /whatdid/controllers/LargeReportController/Sorting.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | struct SortInfo: Equatable { 6 | var key: T 7 | var ascending: Bool 8 | 9 | init(key: T, ascending: Bool) { 10 | self.key = key 11 | self.ascending = ascending 12 | } 13 | 14 | init?(parsedFrom input: String, to enumInit: (String) -> T?) { 15 | if let prefix = input.dropping(suffix: "Ascending"), let enumVal = T(rawValue: prefix) { 16 | key = enumVal 17 | ascending = true 18 | } else if let prefix = input.dropping(suffix: "Descending"), let enumVal = T(rawValue: prefix) { 19 | key = enumVal 20 | ascending = false 21 | } else { 22 | wdlog(.warn, "invalid SortInfo: %@", input) 23 | return nil 24 | } 25 | } 26 | 27 | var sortingFunction: (T.SortedElement, T.SortedElement) -> Bool { 28 | key.sortOrder(ascending: ascending) 29 | } 30 | 31 | var asString: String { 32 | String(describing: key) + (ascending ? "Ascending" : "Descending") 33 | } 34 | 35 | static func == (lhs: SortInfo, rhs: SortInfo) -> Bool { 36 | lhs.key == rhs.key && lhs.ascending == rhs.ascending 37 | } 38 | } 39 | 40 | protocol SortOrder { 41 | associatedtype SortedElement 42 | 43 | init?(rawValue: String) 44 | func sortOrder(ascending: Bool) -> (SortedElement, SortedElement) -> Bool 45 | } 46 | 47 | func createOrdering(using property: @escaping (T) -> C, ascending: Bool) -> (T, T) -> Bool { 48 | if ascending { 49 | return { property($0) < property($1) } 50 | } else { 51 | return { property($0) > property($1) } 52 | } 53 | } 54 | 55 | func createOrdering(lowercased property: @escaping (T) -> String, ascending: Bool) -> (T, T) -> Bool { 56 | createOrdering(using: {property($0).lowercased()}, ascending: ascending) 57 | } 58 | -------------------------------------------------------------------------------- /whatdid/controllers/NonFocusingNSViewController.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class NonFocusingNSViewController: NSViewController { 6 | 7 | override func viewDidAppear() { 8 | self.view.window?.makeFirstResponder(self.view) 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /whatdid/controllers/prefs/PrefsAboutPaneController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrefsAboutPaneController.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 10/9/23. 6 | // Copyright © 2023 Yuval Shavit. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | #if canImport(Sparkle) 11 | import Sparkle 12 | #endif 13 | 14 | class PrefsAboutPaneController: NSViewController { 15 | 16 | 17 | @IBOutlet var shortVersion: NSTextField! 18 | @IBOutlet var copyright: NSTextField! 19 | @IBOutlet var fullVersion: NSTextField! 20 | @IBOutlet var shaVersion: NSButton! 21 | @IBOutlet var githubShaInfo: NSStackView! 22 | @IBOutlet weak var updaterOptions: NSStackView! 23 | 24 | override func viewDidLoad() { 25 | shortVersion.stringValue = shortVersion.stringValue.replacingBracketedPlaceholders(with: [ 26 | "version": Version.short 27 | ]) 28 | copyright.stringValue = copyright.stringValue.replacingBracketedPlaceholders(with: [ 29 | "copyright": Version.copyright 30 | ]) 31 | fullVersion.stringValue = fullVersion.stringValue.replacingBracketedPlaceholders(with: [ 32 | "fullversion": Version.full 33 | ]) 34 | shaVersion.title = shaVersion.title.replacingBracketedPlaceholders(with: [ 35 | "sha": Version.gitSha 36 | ]) 37 | shaVersion.toolTip = shaVersion.toolTip?.replacingBracketedPlaceholders(with: [ 38 | "sha": Version.gitSha.replacingOccurrences(of: ".dirty", with: "") 39 | ]) 40 | githubShaInfo.isHidden = !NSEvent.modifierFlags.contains(.command) 41 | #if !canImport(Sparkle) 42 | updaterOptions.removeFromSuperview() 43 | updaterOptions = nil 44 | #endif 45 | } 46 | 47 | @IBInspectable 48 | dynamic var autoCheckUpdates: Bool { 49 | get { 50 | #if canImport(Sparkle) 51 | AppDelegate.instance.updaterController.updater.automaticallyChecksForUpdates 52 | #else 53 | // This var gets read (via binding) at controller load, before we have a chance to remove the updater options stack. 54 | // That means we do expect it to get invoked even if there's no Sparkle. 55 | false 56 | #endif 57 | } 58 | set (value) { 59 | #if canImport(Sparkle) 60 | AppDelegate.instance.updaterController.updater.automaticallyChecksForUpdates = value 61 | #else 62 | wdlog(.error, "improperly invoked autoCheckUpdates:set without sparkle available") 63 | #endif 64 | } 65 | } 66 | 67 | @IBInspectable 68 | dynamic var includeAlphaReleases: Bool { 69 | get { 70 | #if canImport(Sparkle) 71 | Prefs.updateChannels.contains(.alpha) 72 | #else 73 | // This var gets read (via binding) at controller load, before we have a chance to remove the updater options stack. 74 | // That means we do expect it to get invoked even if there's no Sparkle. 75 | false 76 | #endif 77 | } 78 | set (shouldIncludeAlphas) { 79 | #if canImport(Sparkle) 80 | var newChannels = Prefs.updateChannels 81 | if shouldIncludeAlphas { 82 | newChannels.formUnion([.alpha]) 83 | } else { 84 | newChannels.subtract([.alpha]) 85 | } 86 | Prefs.updateChannels = newChannels 87 | #else 88 | wdlog(.error, "improperly invoked includeAlphaReleases:set without sparkle available") 89 | #endif 90 | } 91 | } 92 | 93 | @IBAction func checkUpdateNow(_ sender: Any) { 94 | #if canImport(Sparkle) 95 | AppDelegate.instance.updaterController.checkForUpdates(sender) 96 | #else 97 | wdlog(.error, "improperly invoked checkUpdateNow without sparkle available") 98 | #endif 99 | } 100 | 101 | @IBAction func href(_ sender: NSButton) { 102 | if let location = sender.toolTip, let url = URL(string: location) { 103 | NSWorkspace.shared.open(url) 104 | } else { 105 | wdlog(.warn, "invalid href: %@", sender.toolTip ?? "") 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /whatdid/controllers/prefs/PrefsFeedbackPaneController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrefsFeedbackPaneController.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 10/9/23. 6 | // Copyright © 2023 Yuval Shavit. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PrefsFeedbackPaneController: NSViewController, NSTextFieldDelegate { 12 | 13 | @IBOutlet var feedbackButton: NSButton! 14 | @IBOutlet weak var privacyUrl: HrefButton! 15 | 16 | override func viewDidLoad() { 17 | if let versionQuery = Version.pretty.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { 18 | feedbackButton.toolTip = feedbackButton.toolTip?.replacingBracketedPlaceholders(with: [ 19 | "version": versionQuery 20 | ]) 21 | } else { 22 | feedbackButton.removeFromSuperview() 23 | } 24 | privacyUrl.toolTip = UsageTracking.PRIVACY_URL 25 | } 26 | 27 | @IBInspectable 28 | dynamic var allowAnalytics: Bool { 29 | get { Prefs.analyticsEnabled } 30 | set(value) { Prefs.analyticsEnabled = value} 31 | } 32 | 33 | @IBAction func showTutorial(_ sender: Any) { 34 | if let myWindow = view.window, let mySheetParent = myWindow.sheetParent { 35 | mySheetParent.endSheet(myWindow, returnCode: PrefsViewController.SHOW_TUTORIAL) 36 | } 37 | } 38 | 39 | @IBAction func href(_ sender: NSButton) { 40 | if let location = sender.toolTip, let url = URL(string: location) { 41 | NSWorkspace.shared.open(url) 42 | } else { 43 | wdlog(.warn, "invalid href: %@", sender.toolTip ?? "") 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /whatdid/controllers/prefs/PrefsViewController.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | import KeyboardShortcuts 5 | 6 | class PrefsViewController: NSViewController { 7 | public static let SHOW_TUTORIAL = NSApplication.ModalResponse(27) 8 | public static let CLOSE_PTN = NSApplication.ModalResponse(28) 9 | @Pref(key: "prefsview.openItem") private static var openTab = 0 10 | 11 | @IBOutlet private var outerVStackWidth: NSLayoutConstraint! 12 | @IBOutlet var outerVStackMinHeight: NSLayoutConstraint! 13 | private var desiredWidth: CGFloat = 0 14 | private var minHeight: CGFloat = 0 15 | 16 | @IBOutlet var tabButtonsStack: NSStackView! 17 | @IBOutlet var mainTabs: NSTabView! 18 | 19 | // Hooks to general prefs controller 20 | @IBOutlet var generalPrefsController: PrefsGeneralPaneController! 21 | var ptnScheduleChanged: () -> Void { 22 | get { 23 | generalPrefsController.ptnScheduleChanged 24 | } 25 | set { 26 | generalPrefsController.ptnScheduleChanged = newValue 27 | } 28 | } 29 | var snoozeUntilTomorrowInfo: (hhMm: HoursAndMinutes, includeWeekends: Bool) { 30 | generalPrefsController.snoozeUntilTomorrowInfo 31 | } 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | outerVStackWidth.constant = desiredWidth 36 | outerVStackMinHeight.constant = minHeight 37 | 38 | tabButtonsStack.wantsLayer = true 39 | 40 | tabButtonsStack.subviews.forEach {$0.removeFromSuperview()} 41 | for (i, tab) in mainTabs.tabViewItems.enumerated() { 42 | let text = tab.label 43 | let button = ButtonWithClosure(label: text) {_ in 44 | self.selectPane(at: i) 45 | } 46 | button.bezelStyle = .smallSquare 47 | button.image = tab.value(forKey: "image") as? NSImage 48 | button.imagePosition = .imageLeading 49 | button.imageScaling = .scaleProportionallyDown 50 | button.setButtonType(.pushOnPushOff) 51 | button.focusRingType = .none 52 | button.setAccessibilityRole(.button) 53 | button.setAccessibilitySubrole(.tabButtonSubrole) 54 | tabButtonsStack.addArrangedSubview(button) 55 | } 56 | tabButtonsStack.addArrangedSubview(NSView()) // trailing spacer 57 | } 58 | 59 | override func viewDidAppear() { 60 | UsageTracking.recordAction(.OpenSettingsPane) 61 | } 62 | 63 | override func viewWillAppear() { 64 | NSAppearance.withEffectiveAppearance { 65 | tabButtonsStack.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor 66 | } 67 | if !mainTabs.tabViewItems.isEmpty { 68 | selectPane(at: PrefsViewController.openTab) 69 | } 70 | } 71 | 72 | private func selectPane(at index: Int) { 73 | for (otherButtonIdx, subview) in self.tabButtonsStack.arrangedSubviews.enumerated() { 74 | let state: NSControl.StateValue = otherButtonIdx == index ? .on : .off 75 | (subview as? NSButton)?.state = state 76 | } 77 | 78 | self.mainTabs.selectTabViewItem(at: index) 79 | view.layoutSubtreeIfNeeded() 80 | view.window?.setContentSize(view.fittingSize) 81 | PrefsViewController.openTab = index 82 | } 83 | 84 | func setSize(width: CGFloat, minHeight: CGFloat) { 85 | self.desiredWidth = width 86 | self.minHeight = minHeight 87 | } 88 | 89 | @IBAction func quitButton(_ sender: Any) { 90 | endParentSheet(with: .stop) 91 | } 92 | 93 | @IBAction func cancelButton(_ sender: Any) { 94 | endParentSheet(with: .cancel) 95 | } 96 | 97 | private func endParentSheet(with response: NSApplication.ModalResponse) { 98 | if let myWindow = view.window, let mySheetParent = myWindow.sheetParent { 99 | mySheetParent.endSheet(myWindow, returnCode: response) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /whatdid/extensions/Array+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | extension Array where Element: Hashable { 4 | func distinct() -> Set { 5 | return Set(self) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /whatdid/extensions/Date+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension Date { 6 | 7 | func timestamp(at timeZone: TimeZone) -> String { 8 | let options = ISO8601DateFormatter.Options([.withInternetDateTime, .withFractionalSeconds]) 9 | return ISO8601DateFormatter.string(from: self, timeZone: timeZone, formatOptions: options) 10 | } 11 | 12 | var utcTimestamp: String { 13 | get { 14 | timestamp(at: TimeZone.utc) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /whatdid/extensions/Int+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension Int { 6 | func clipped(to range: ClosedRange) -> Int { 7 | if self < range.lowerBound { 8 | return range.lowerBound 9 | } else if self > range.upperBound { 10 | return range.upperBound 11 | } 12 | return self 13 | } 14 | 15 | func pluralize(_ singular: String, _ plural: String, showValue: Bool = true) -> String { 16 | let s = (self == 1) ? singular : plural 17 | return showValue ? "\(self) \(s)" : s 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /whatdid/extensions/NSApperance+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSAppearance { 6 | /// Fetches things like the latest NSColor values. 7 | /// 8 | /// See: https://stackoverflow.com/a/63859580/1076640 9 | static func withEffectiveAppearance(_ block: () -> Void) { 10 | let previousAppearance = NSAppearance.current 11 | defer { 12 | NSAppearance.current = previousAppearance 13 | } 14 | NSAppearance.current = NSApp.effectiveAppearance 15 | block() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /whatdid/extensions/NSButton+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSButton { 6 | #if UI_TEST 7 | /// Cycles the button's styles, so that you can see which you like best 8 | func cycleStylesForUIBikeshedding() { 9 | let allStyles: [(String, NSButton.BezelStyle)] = [ 10 | ("circular", .circular), 11 | ("disclosure", .disclosure), 12 | ("helpButton", .helpButton), 13 | ("inline", .inline), 14 | ("recessed", .recessed), 15 | ("regularSquare", .regularSquare), 16 | ("roundRect", .roundRect), 17 | ("rounded", .rounded), 18 | ("roundedDisclosure", .roundedDisclosure), 19 | ("shadowlessSquare", .shadowlessSquare), 20 | ("smallSquare", .smallSquare), 21 | ("texturedRounded", .texturedRounded), 22 | ("texturedSquare", .texturedSquare), 23 | ] 24 | var i = 0 25 | func cycleOnce() { 26 | let style = allStyles[i] 27 | i = (i + 1) % allStyles.count 28 | wdlog(.info, "styling as: %@", style.0) 29 | bezelStyle = style.1 30 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(1000), execute: cycleOnce) 31 | } 32 | cycleOnce() 33 | } 34 | #endif 35 | } 36 | -------------------------------------------------------------------------------- /whatdid/extensions/NSControl+Helper.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSTextView { 6 | private static let commandKey = NSEvent.ModifierFlags.command.rawValue 7 | private static let commandShiftKey = NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue 8 | 9 | /// Copied from [SO#970707](https://stackoverflow.com/a/54492165/1076640) with slight modifications. 10 | override open func performKeyEquivalent(with event: NSEvent) -> Bool { 11 | if event.type == NSEvent.EventType.keyDown { 12 | let action: Selector? 13 | if checkFlags(on: event, for: NSTextView.commandKey) { 14 | switch event.charactersIgnoringModifiers! { 15 | case "x": 16 | action = #selector(NSText.cut(_:)) 17 | case "c": 18 | action = #selector(NSText.copy(_:)) 19 | case "v": 20 | action = #selector(NSText.paste(_:)) 21 | case "z": 22 | action = Selector(("undo:")) 23 | case "a": 24 | action = #selector(NSResponder.selectAll(_:)) 25 | default: 26 | action = nil 27 | break 28 | } 29 | } else if checkFlags(on: event, for: NSTextView.commandShiftKey) && event.charactersIgnoringModifiers == "Z" { 30 | action = Selector(("redo:")) 31 | } else { 32 | action = nil 33 | } 34 | if let useAction = action, NSApp.sendAction(useAction, to: nil, from: self) { 35 | return true 36 | } 37 | } 38 | return super.performKeyEquivalent(with: event) 39 | } 40 | 41 | private func checkFlags(on event: NSEvent, for flags: UInt) -> Bool { 42 | return (event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) == flags 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /whatdid/extensions/NSRange+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSRange { 6 | static func arrayFrom(ints unsorted: [Int]) -> [NSRange] { 7 | if unsorted.isEmpty { 8 | return [] 9 | } 10 | var results = [NSRange]() 11 | 12 | let sortedInts = unsorted.sorted() 13 | var sequenceLowValue = sortedInts[0] 14 | var sequenceHighValue = sequenceLowValue 15 | for value in sortedInts[1...] { 16 | if value > sequenceHighValue + 1 { 17 | results.append(from(low: sequenceLowValue, toHigh: sequenceHighValue)) 18 | sequenceLowValue = value 19 | sequenceHighValue = value 20 | } else { 21 | sequenceHighValue = value 22 | } 23 | } 24 | results.append(from(low: sequenceLowValue, toHigh: sequenceHighValue)) 25 | return results 26 | } 27 | 28 | private static func from(low: Int, toHigh high: Int) -> NSRange { 29 | NSRange(location: low, length: high - low + 1) // +1 because e.g. [3, 3] has length 1 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /whatdid/extensions/NSRecursiveLock+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | extension NSRecursiveLock { 6 | 7 | func synchronized(_ block: () -> Void) { 8 | let _ = synchronizedGet {() -> Bool in 9 | block() 10 | return false 11 | } 12 | } 13 | 14 | func synchronizedGet(_ block: () -> T) -> T { 15 | lock() 16 | defer { unlock() } 17 | return block() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /whatdid/extensions/NSStackView+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSStackView { 6 | convenience init(orientation: NSUserInterfaceLayoutOrientation, _ with: NSView...) { 7 | self.init() 8 | useAutoLayout() 9 | self.orientation = orientation 10 | with.forEach(self.addArrangedSubview(_:)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /whatdid/extensions/NSTableView+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSTableView { 6 | var visibleRowIndexes: IndexSet { 7 | var result = IndexSet(integersIn: 0..>(wrappedValue: Set()) 18 | 19 | /// Flashes the placeholder text to red and back. 20 | /// 21 | /// The "and back" bit has an interesting wrinkle: if you were to flash this while a previous animation is still going on, the second flash would 22 | /// think its target is the first animation's partial state; so that second animation would go from that partial state, to red, back to that partial state. 23 | /// That would make the red-ness sticky, which we don't want. To get around that, we maintain a static, atomic set of all in-progress fields, and 24 | /// use this from blocking duplicates. 25 | func flash(field: NSTextField) { 26 | var isAlreadyAnimating = false 27 | TextFlasherAnimation.inProgressFields.modifyInPlace {set in 28 | let (wasInserted, _) = set.insert(field) 29 | isAlreadyAnimating = !wasInserted 30 | } 31 | if isAlreadyAnimating { 32 | return 33 | } 34 | guard let (_, _, originalColor) = TextFlasherAnimation.getPlaceholderAndColor(on: field) else { 35 | return 36 | } 37 | PlaceholderAnimation().flashPlaceholder(field: field, to: .red, over: 0.1) { 38 | PlaceholderAnimation().flashPlaceholder(field: field, to: originalColor, over: 0.3) { 39 | TextFlasherAnimation.inProgressFields.modifyInPlace {set in 40 | set.remove(field) 41 | } 42 | } 43 | } 44 | } 45 | 46 | private static func getPlaceholderAndColor(on field: NSTextField) -> PlaceholderInfo? { 47 | guard let placeholder = field.placeholderAttributedString else { 48 | return nil 49 | } 50 | let attributes = placeholder.attributes(at: 0, effectiveRange: nil) 51 | guard let placeholderColor = attributes[.foregroundColor] as? NSColor else { 52 | return nil 53 | } 54 | return (placeholder, attributes, placeholderColor) 55 | } 56 | 57 | private class PlaceholderAnimation: NSAnimation, NSAnimationDelegate { 58 | private var field: NSTextField! 59 | private var targetColor: NSColor! 60 | private var origPlaceholder: NSAttributedString! 61 | private var origColor: NSColor! 62 | private var attributes: [NSAttributedString.Key : Any]! 63 | private var onComplete: Action! 64 | 65 | func flashPlaceholder(field: NSTextField, to target: NSColor, over: TimeInterval, andThen onComplete: @escaping Action) { 66 | guard let (placeholder, attributes, placeholderColor) = TextFlasherAnimation.getPlaceholderAndColor(on: field) else { 67 | return 68 | } 69 | self.origPlaceholder = placeholder 70 | self.attributes = attributes 71 | self.origColor = placeholderColor 72 | self.field = field 73 | 74 | self.targetColor = target 75 | self.duration = over 76 | self.animationBlockingMode = .nonblocking 77 | self.delegate = self 78 | self.onComplete = onComplete 79 | 80 | self.start() 81 | } 82 | 83 | override var currentProgress: NSAnimation.Progress { 84 | get { super.currentProgress } 85 | set (value) { 86 | let newColor = origColor.blended(withFraction: CGFloat(value), of: targetColor) 87 | attributes[.foregroundColor] = newColor 88 | field.placeholderAttributedString = NSAttributedString(string: origPlaceholder.string, attributes: attributes) 89 | super.currentProgress = value 90 | } 91 | } 92 | 93 | func animationDidEnd(_ animation: NSAnimation) { 94 | onComplete() 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /whatdid/extensions/NSView+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension NSView { 6 | 7 | func anchorAllSides(to other: NSView) { 8 | useAutoLayout() 9 | topAnchor.constraint(equalTo: other.topAnchor).isActive = true 10 | leadingAnchor.constraint(equalTo: other.leadingAnchor).isActive = true 11 | bottomAnchor.constraint(equalTo: other.bottomAnchor).isActive = true 12 | trailingAnchor.constraint(equalTo: other.trailingAnchor).isActive = true 13 | } 14 | 15 | func useAutoLayout() { 16 | translatesAutoresizingMaskIntoConstraints = false 17 | } 18 | 19 | func contains(pointInWindowCoordinates: NSPoint) -> Bool { 20 | let pointInSuperviewCoordinates = superview!.convert(pointInWindowCoordinates, from: nil) 21 | return frame.contains(pointInSuperviewCoordinates) 22 | } 23 | 24 | #if UI_TEST 25 | func printConstraints() { 26 | func doPrint(view: NSView, indent: Int) { 27 | func p(_ elem: Any) { 28 | print(String(repeating: " ", count: indent), elem, separator: "") 29 | } 30 | let excuse = view.constraints.isEmpty && view.subviews.isEmpty ? " (no constraints or subviews)" : "" 31 | p(view.className + excuse) 32 | if excuse.isEmpty { 33 | p(String(repeating: "─", count: view.className.count)) 34 | } 35 | for c in view.constraintsAffectingLayout(for: .horizontal) { 36 | p("↔︎ \(c)") 37 | } 38 | for c in view.constraintsAffectingLayout(for: .vertical) { 39 | p("↕︎ \(c)") 40 | } 41 | print("") 42 | for v in view.subviews { 43 | doPrint(view: v, indent: indent + 1) 44 | } 45 | } 46 | doPrint(view: self, indent: 0) 47 | } 48 | #endif 49 | } 50 | -------------------------------------------------------------------------------- /whatdid/extensions/OSLogType+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import os 4 | 5 | extension OSLogType { 6 | /// Equivalent to `.default`. 7 | /// 8 | /// This is a clearer label for that `.default`'s description: 9 | /// "Use this level to capture information about things that might result in a failure." 10 | static let warn = OSLogType.default 11 | 12 | var asString: String { 13 | get { 14 | switch self { 15 | case .debug: return "DEBUG" 16 | case .default: return "WARN " 17 | case .error: return "ERROR" 18 | case .fault: return "FAULT" 19 | case .info: return "INFO " 20 | default: return "(log@\(rawValue))" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /whatdid/extensions/OutputStream+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | 6 | let newlineUtfData = "\n".data(using: .utf8)! 7 | 8 | extension OutputStream { 9 | // taken from https://stackoverflow.com/a/26992040/1076640 10 | 11 | enum OutputStreamError: Error { 12 | case stringConversionFailure 13 | case bufferFailure 14 | case writeFailure 15 | } 16 | 17 | func write(_ string: String) throws { 18 | guard let data = string.data(using: .utf8) else { 19 | throw OutputStreamError.stringConversionFailure 20 | } 21 | try write(data) 22 | } 23 | 24 | func write(_ data: Data) throws { 25 | try data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in 26 | guard var pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { 27 | throw OutputStreamError.bufferFailure 28 | } 29 | 30 | var bytesRemaining = buffer.count 31 | 32 | while bytesRemaining > 0 { 33 | let bytesWritten = write(pointer, maxLength: bytesRemaining) 34 | if bytesWritten < 0 { 35 | throw OutputStreamError.writeFailure 36 | } 37 | 38 | bytesRemaining -= bytesWritten 39 | pointer += bytesWritten 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /whatdid/extensions/String+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension String { 6 | func fullNsRange() -> NSRange { 7 | NSRange(location: 0, length: count) 8 | } 9 | 10 | func replacingBracketedPlaceholders(with replacements: [String: String]) -> String { 11 | var result = self 12 | for (key, value) in replacements { 13 | result = result.replacingOccurrences(of: "{\(key)}", with: value) 14 | } 15 | return result 16 | } 17 | 18 | /// Drops the suffix from this string, if it is present. Returns the resulting string if the suffix was there, or `nil` if it wasn't. 19 | func dropping(suffix: String) -> String? { 20 | if hasSuffix(suffix) { 21 | return String(dropLast(suffix.count)) 22 | } else { 23 | return nil 24 | } 25 | } 26 | 27 | var stableHash: UInt32 { 28 | var hash: UInt32 = 0 29 | for byte in self.utf8 { 30 | hash = hash &* 31 &+ UInt32(byte) 31 | } 32 | return hash 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /whatdid/extensions/TimeZone+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | extension TimeZone { 6 | static let utc = TimeZone(identifier: "UTC")! 7 | } 8 | -------------------------------------------------------------------------------- /whatdid/extensions/UInt32+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | extension UInt32 { 6 | public var asUnitFloat: Float { 7 | let uintAsDouble = Double(self) 8 | let scaledTo1 = uintAsDouble / Double(UInt32.max) 9 | return Float(scaledTo1) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /whatdid/extensions/UUID+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | extension UUID { 6 | static let zero = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! 7 | } 8 | -------------------------------------------------------------------------------- /whatdid/main/AutoCompletingField.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class AutoCompletingField: TextFieldWithPopup, NSAccessibilityGroup { 6 | 7 | fileprivate static let PINNED_OPTIONS_COUNT = 3 8 | 9 | var onAction: (AutoCompletingField) -> Void = {_ in} 10 | var optionsLookup: (() -> [String])? 11 | 12 | /// Characters to ignore when reporting the accessibility value 13 | var accessibilityStringIgnoredChars: CharacterSet { 14 | get { 15 | optionsList.accessibilityStringIgnoredChars 16 | } set (value) { 17 | optionsList.accessibilityStringIgnoredChars = value 18 | } 19 | } 20 | 21 | var emptyOptionsPlaceholder: String { 22 | get { optionsList.emptyOptionsPlaceholder } 23 | set { optionsList.emptyOptionsPlaceholder = newValue } 24 | } 25 | 26 | private var optionsList: TextOptionsList { 27 | contents as! TextOptionsList 28 | } 29 | 30 | override func finishInit() { 31 | self.contents = TextOptionsList() 32 | target = self 33 | action = #selector(textFieldViewAction(_:)) 34 | } 35 | 36 | @objc private func textFieldViewAction(_ sender: NSTextField) { 37 | onAction(self) 38 | } 39 | 40 | override func showOptions() { 41 | if let optionsLookup = optionsLookup { 42 | options = optionsLookup() 43 | } 44 | super.showOptions() 45 | } 46 | 47 | func makeFirstResponderWithoutShowingPopup() { 48 | guard let window = window else { 49 | return 50 | } 51 | let prevSetting = automaticallyShowPopup 52 | automaticallyShowPopup = false 53 | defer { 54 | automaticallyShowPopup = prevSetting 55 | } 56 | window.makeFirstResponder(self) 57 | } 58 | 59 | var options: [String] { 60 | get { 61 | return optionsList.options 62 | } 63 | set (values) { 64 | optionsList.options = values 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /whatdid/main/Base.lproj/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /whatdid/main/GlobalShortcut.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import KeyboardShortcuts 4 | 5 | extension KeyboardShortcuts.Name { 6 | static let grabFocus = Self("grabFocus") 7 | } 8 | -------------------------------------------------------------------------------- /whatdid/model/Entry+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | @objc(Entry) 7 | public class Entry: NSManagedObject { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /whatdid/model/Entry+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | 7 | extension Entry { 8 | 9 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 10 | return NSFetchRequest(entityName: "Entry") 11 | } 12 | 13 | @NSManaged public var notes: String? 14 | @NSManaged public var timeApproximatelyStarted: Date 15 | @NSManaged public var timeEntered: Date 16 | @NSManaged public var task: Task 17 | 18 | } 19 | -------------------------------------------------------------------------------- /whatdid/model/EntryExportFormats.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | import Foundation 5 | import SwiftUI 6 | 7 | protocol EntryExportFormat { 8 | var name: String { get } 9 | var fileExtension: String { get } 10 | func write(entries: [FlatEntry], to out: OutputStream) throws 11 | } 12 | 13 | let allEntryExportFormats: [EntryExportFormat] = [ 14 | TextTreeEntryExportFormat(), 15 | JsonEntryExportFormat(), 16 | CsvEntryExportFormat(), 17 | ] 18 | 19 | class JsonEntryExportFormat : EntryExportFormat { 20 | 21 | let name = "json" 22 | let fileExtension = "json" 23 | 24 | func write(entries flatEntries: [FlatEntry], to out: OutputStream) throws { 25 | let projects = Model.GroupedProjects(from: flatEntries) 26 | var projectByName = [String: Project]() 27 | projects.forEach {project in 28 | var tasksByName = [String: Task]() 29 | project.forEach {task in 30 | var entries = [Entry]() 31 | task.forEach { e in 32 | entries.append(Entry(from: e.from, to: e.to, notes: e.notes ?? "")) 33 | } 34 | tasksByName[task.name] = entries 35 | } 36 | projectByName[project.name] = tasksByName 37 | } 38 | 39 | let encoder = JSONEncoder() 40 | encoder.dateEncodingStrategy = .iso8601 41 | let jsonData = try encoder.encode(projectByName) 42 | try out.write(jsonData) 43 | } 44 | 45 | private typealias Project = [String: Task] 46 | 47 | private typealias Task = [Entry] 48 | 49 | private struct Entry: Codable { 50 | let from : Date 51 | let to : Date 52 | let notes : String 53 | } 54 | } 55 | 56 | class TextTreeEntryExportFormat : EntryExportFormat { 57 | 58 | let name = "text" 59 | let fileExtension = "txt" 60 | 61 | func write(entries flatEntries: [FlatEntry], to out: OutputStream) throws { 62 | let projects = Model.GroupedProjects(from: flatEntries) 63 | let totalTime = projects.totalTime 64 | try out.write("Total time: ") 65 | try out.write(TimeUtil.daysHoursMinutes(for: totalTime)) 66 | try out.write(newlineUtfData) 67 | 68 | func write(item: String, timeSpent: TimeInterval, indentBy indent: Int) throws { 69 | try out.write(String(repeating: " ", count: indent * 4)) 70 | try out.write(String( 71 | format: "%.1f%% (%@): %@\n", 72 | (timeSpent / totalTime) * 100.0, 73 | TimeUtil.daysHoursMinutes(for: timeSpent), item)) 74 | } 75 | 76 | try projects.forEach {project in 77 | try write(item: project.name, timeSpent: project.totalTime, indentBy: 1) 78 | try project.forEach {task in 79 | try write(item: task.name, timeSpent: task.totalTime, indentBy: 2) 80 | try task.forEach { entry in 81 | var notes = entry.notes ?? "" 82 | if notes.isEmpty { 83 | notes = "(no notes entered)" 84 | } 85 | try write(item: notes, timeSpent: entry.duration, indentBy: 3) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | class CsvEntryExportFormat : EntryExportFormat { 93 | var name = "csv" 94 | 95 | var fileExtension = "csv" 96 | 97 | func write(entries: [FlatEntry], to out: OutputStream) throws { 98 | guard let comma = ",".data(using: .utf8) else { 99 | throw OutputStream.OutputStreamError.stringConversionFailure 100 | } 101 | func field(_ string: String?, withDelimiter delimiter: Data = comma) throws { 102 | let string = string ?? "" 103 | if !string.isEmpty { 104 | let quoted = "\"" + string.replacingOccurrences(of: "\"", with: "\"\"") + "\"" 105 | try out.write(quoted) 106 | } 107 | try out.write(delimiter) 108 | } 109 | 110 | try out.write("start_time,end_time,project,task,notes\n") 111 | 112 | let dateFormatter = ISO8601DateFormatter() 113 | try entries.sorted(by: {$0.from < $1.from && $0.to < $1.to}).forEach {entry in 114 | try field(dateFormatter.string(from: entry.from)) 115 | try field(dateFormatter.string(from: entry.to)) 116 | try field(entry.project) 117 | try field(entry.task) 118 | try field(entry.notes, withDelimiter: newlineUtfData) 119 | } 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /whatdid/model/FlatEntry+Serde.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | #if UI_TEST 3 | import Cocoa 4 | import os 5 | 6 | // NOTE: This file gets compiled both in main whatdid and whatdidUITests. 7 | // The latter doesn't have access to `wdlog`, so we can't use that here. 8 | 9 | extension FlatEntry { 10 | 11 | static func deserialize(from json: String) -> [FlatEntry] { 12 | if json.trimmingCharacters(in: .whitespaces).isEmpty { 13 | return [] 14 | } 15 | let decoder = JSONDecoder() 16 | decoder.dateDecodingStrategy = .millisecondsSince1970 17 | if let jsonData = json.data(using: .utf8) { 18 | do { 19 | return try decoder.decode([FlatEntry].self, from: jsonData) 20 | } catch { 21 | os_log(.error, "Error deserializing %@: %@", json, error as NSError) 22 | return [] 23 | } 24 | } else { 25 | os_log(.error, "Couldn't get UTF-8 data from string: %@", json) 26 | return [] 27 | } 28 | } 29 | 30 | static func serialize(_ nodes: FlatEntry...) -> String { 31 | return serialize(nodes) 32 | } 33 | 34 | static func serialize(_ nodes: [FlatEntry]) -> String { 35 | if nodes.isEmpty { 36 | return "" 37 | } 38 | do { 39 | let encoder = JSONEncoder() 40 | encoder.dateEncodingStrategy = .millisecondsSince1970 41 | let jsonData = try encoder.encode(nodes) 42 | return String(data: jsonData, encoding: .utf8)! 43 | } catch { 44 | os_log(.error, "failed to encode entries: %@", String(describing: nodes), error as NSError) 45 | return "" 46 | } 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /whatdid/model/FlatEntry.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | import Cocoa 3 | 4 | struct FlatEntry: CustomStringConvertible, Codable, Equatable, Hashable { 5 | 6 | let from : Date 7 | let to : Date 8 | let project : String 9 | let task : String 10 | let notes : String? 11 | 12 | var duration: TimeInterval { 13 | get { 14 | return (to.timeIntervalSince1970 - from.timeIntervalSince1970) 15 | } 16 | } 17 | 18 | var description: String { 19 | String( 20 | format: "project(%@), task(%@), notes(%@) from %@ to %@", 21 | project, 22 | task, 23 | notes ?? "", 24 | from.debugDescription, 25 | to.debugDescription 26 | ) 27 | } 28 | 29 | func replacing(project: String, task: String, notes: String?) -> FlatEntry { 30 | let maybeNotes = notes.flatMap({$0.isEmpty ? nil : $0}) 31 | return FlatEntry(from: from, to: to, project: project, task: task, notes: maybeNotes) 32 | } 33 | } 34 | 35 | struct RewriteableFlatEntry { 36 | let entry: FlatEntry 37 | let objectId: NSManagedObjectID 38 | 39 | func map(modify: (FlatEntry) -> FlatEntry) -> RewrittenFlatEntry { 40 | RewrittenFlatEntry(original: self, newValue: modify(entry)) 41 | } 42 | } 43 | 44 | struct RewrittenFlatEntry { 45 | let original: RewriteableFlatEntry 46 | let newValue: FlatEntry 47 | } 48 | 49 | extension Array where Element == RewriteableFlatEntry { 50 | var withoutObjectIds: [FlatEntry] { 51 | map({$0.entry}) 52 | } 53 | } -------------------------------------------------------------------------------- /whatdid/model/Goal+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Goal+CoreDataClass.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 3/24/21. 6 | // Copyright © 2021 Yuval Shavit. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | @objc(Goal) 14 | public class Goal: NSManagedObject { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /whatdid/model/Goal+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Goal+CoreDataProperties.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 3/24/21. 6 | // Copyright © 2021 Yuval Shavit. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | 14 | extension Goal { 15 | 16 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 17 | return NSFetchRequest(entityName: "Goal") 18 | } 19 | 20 | @NSManaged public var goal: String 21 | @NSManaged public var created: Date! 22 | @NSManaged public var completed: Date? 23 | @NSManaged public var during: Session? 24 | @NSManaged public var orderWithinSession: NSNumber? 25 | 26 | } 27 | -------------------------------------------------------------------------------- /whatdid/model/Project+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | @objc(Project) 7 | public class Project: NSManagedObject { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /whatdid/model/Project+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | 7 | extension Project { 8 | 9 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 10 | return NSFetchRequest(entityName: "Project") 11 | } 12 | 13 | @NSManaged public var lastUsed: Date 14 | @NSManaged public var project: String 15 | @NSManaged public var tasks: Set 16 | 17 | } 18 | 19 | // MARK: Generated accessors for tasks 20 | extension Project { 21 | 22 | @objc(addTasksObject:) 23 | @NSManaged public func addToTasks(_ value: Task) 24 | 25 | @objc(removeTasksObject:) 26 | @NSManaged public func removeFromTasks(_ value: Task) 27 | 28 | @objc(addTasks:) 29 | @NSManaged public func addToTasks(_ values: NSSet) 30 | 31 | @objc(removeTasks:) 32 | @NSManaged public func removeFromTasks(_ values: NSSet) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /whatdid/model/Session+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session+CoreDataClass.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 3/24/21. 6 | // Copyright © 2021 Yuval Shavit. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | @objc(Session) 14 | public class Session: NSManagedObject { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /whatdid/model/Session+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session+CoreDataProperties.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 3/24/21. 6 | // Copyright © 2021 Yuval Shavit. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | import CoreData 12 | 13 | 14 | extension Session { 15 | 16 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 17 | return NSFetchRequest(entityName: "Session") 18 | } 19 | 20 | @NSManaged public var startTime: Date? 21 | @NSManaged public var goals: Set 22 | 23 | } 24 | 25 | // MARK: Generated accessors for goals 26 | extension Session { 27 | 28 | @objc(addGoalsObject:) 29 | @NSManaged public func addToGoals(_ value: Goal) 30 | 31 | @objc(removeGoalsObject:) 32 | @NSManaged public func removeFromGoals(_ value: Goal) 33 | 34 | @objc(addGoals:) 35 | @NSManaged public func addToGoals(_ values: NSSet) 36 | 37 | @objc(removeGoals:) 38 | @NSManaged public func removeFromGoals(_ values: NSSet) 39 | 40 | } 41 | -------------------------------------------------------------------------------- /whatdid/model/Task+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | @objc(Task) 7 | public class Task: NSManagedObject { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /whatdid/model/Task+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | 7 | extension Task { 8 | 9 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 10 | return NSFetchRequest(entityName: "Task") 11 | } 12 | 13 | @NSManaged public var lastUsed: Date 14 | @NSManaged public var task: String 15 | @NSManaged public var entries: Set 16 | @NSManaged public var project: Project 17 | 18 | @NSManaged private var projectName: String? 19 | 20 | public override func awakeFromFetch() { 21 | projectName = project.project 22 | super.awakeFromFetch() 23 | } 24 | 25 | public override func validateForUpdate() throws { 26 | setProjectName() 27 | try super.validateForUpdate() 28 | } 29 | 30 | public override func validateForDelete() throws { 31 | setProjectName() 32 | try super.validateForDelete() 33 | } 34 | 35 | public override func validateForInsert() throws { 36 | setProjectName() 37 | try super.validateForInsert() 38 | } 39 | 40 | private func setProjectName() { 41 | if projectName == nil { 42 | projectName = project.project 43 | } 44 | } 45 | } 46 | 47 | // MARK: Generated accessors for entries 48 | extension Task { 49 | 50 | @objc(addEntriesObject:) 51 | @NSManaged public func addToEntries(_ value: Entry) 52 | 53 | @objc(removeEntriesObject:) 54 | @NSManaged public func removeFromEntries(_ value: Entry) 55 | 56 | @objc(addEntries:) 57 | @NSManaged public func addToEntries(_ values: NSSet) 58 | 59 | @objc(removeEntries:) 60 | @NSManaged public func removeFromEntries(_ values: NSSet) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /whatdid/model/UsageDatumDTO.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | import CoreData 5 | 6 | struct UsageDatumDTO { 7 | let objectID: NSManagedObjectID 8 | 9 | let datumId: UUID 10 | let trackerId: UUID 11 | let action: String 12 | let timestamp: Date 13 | let sendSuccess: Date? 14 | } 15 | 16 | extension UsageDatumDTO { 17 | init?(from managed: UsageDatum) { 18 | guard let datumId = managed.datumId, 19 | let trackerId = managed.trackerId, 20 | let action = managed.action, 21 | let timestamp = managed.timestamp 22 | else { 23 | return nil 24 | } 25 | self.objectID = managed.objectID 26 | self.datumId = datumId 27 | self.trackerId = trackerId 28 | self.action = action 29 | self.timestamp = timestamp 30 | self.sendSuccess = managed.sendSuccess 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /whatdid/scheduling/DefaultScheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | struct DefaultScheduler { 6 | 7 | #if UI_TEST 8 | static let instance = ManualTickScheduler() 9 | #else 10 | static let instance: Scheduler = SystemClockScheduler() 11 | #endif 12 | 13 | private init() { 14 | // nothing 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /whatdid/scheduling/DelegatingScheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class DelegatingScheduler: Scheduler { 6 | private let delegate: Scheduler 7 | private var tasks = [UUID: ScheduledTask]() 8 | private var isOpen = true 9 | 10 | init(delegateTo delegate: Scheduler) { 11 | self.delegate = delegate 12 | } 13 | 14 | var now: Date { 15 | delegate.now 16 | } 17 | 18 | var timeZone: TimeZone { 19 | delegate.timeZone 20 | } 21 | 22 | var calendar: Calendar { 23 | delegate.calendar 24 | } 25 | 26 | /// The number of tasks currently being tracked by this scheduler. Intended for testing (to ensure there are no memory leaks). 27 | var tasksCount: Int { 28 | return tasks.count 29 | } 30 | 31 | @discardableResult func schedule(_ description: String, at: Date, _ block: @escaping () -> Void) -> ScheduledTask { 32 | guard isOpen else { 33 | wdlog(.debug, "Ignoring task because DelegatingScheduler has been closed") 34 | return NoopTask() 35 | } 36 | let work = createTrackingBlock(block) 37 | work.tracks = delegate.schedule(description, at: at, work.run) 38 | return work 39 | } 40 | 41 | func close() { 42 | for id in tasks.keys { 43 | let task = tasks.removeValue(forKey: id) 44 | task?.cancel() 45 | } 46 | isOpen = false 47 | } 48 | 49 | private func createTrackingBlock(_ block: @escaping () -> Void) -> Tracker { 50 | let tracker = Tracker(parent: self, task: block) 51 | tasks[tracker.id] = tracker 52 | return tracker 53 | } 54 | 55 | private struct NoopTask: ScheduledTask { 56 | func cancel() { 57 | // nothing 58 | } 59 | } 60 | 61 | private class Tracker: ScheduledTask { 62 | let id = UUID() 63 | let parent: DelegatingScheduler 64 | let task: () -> Void 65 | var tracks: ScheduledTask? = nil 66 | 67 | init(parent: DelegatingScheduler, task: @escaping () -> Void) { 68 | self.parent = parent 69 | self.task = task 70 | } 71 | 72 | func run() { 73 | parent.tasks.removeValue(forKey: id) 74 | task() 75 | } 76 | 77 | func cancel() { 78 | parent.tasks.removeValue(forKey: id) 79 | tracks?.cancel() 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /whatdid/scheduling/ManualTickScheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | #if UI_TEST 3 | import Foundation 4 | 5 | class ManualTickScheduler: Scheduler { 6 | private var _now = Date(timeIntervalSince1970: 0) 7 | private var events = [WorkItem]() 8 | private var listeners = [(Date) -> Void]() 9 | 10 | func reset() { 11 | _now = Date(timeIntervalSince1970: 0) 12 | listeners.forEach { $0(_now) } 13 | events = [] 14 | } 15 | 16 | @discardableResult func schedule(_ description: String, at date: Date, _ block: @escaping () -> Void) -> ScheduledTask { 17 | wdlog(.debug, "Scheduling %{public}@ at %{public}@ (t+%.0f)", description, date as NSDate, date.timeIntervalSinceWhatdidNow) 18 | if date == _now { 19 | enqueueAction(block) 20 | return NoopScheduledItem() 21 | } else if date > _now { 22 | let uuid = UUID() 23 | events.append(WorkItem(id: uuid, fireAt: date, block: block)) 24 | return ManualScheduledItem(parent: self, id: uuid) 25 | } else { 26 | wdlog(.warn, "ignoring event because it's in the past (%{public}@)", date as NSDate) 27 | return NoopScheduledItem() 28 | } 29 | } 30 | 31 | func addListener(_ listener: @escaping (Date) -> Void) { 32 | listeners.append(listener) 33 | } 34 | 35 | var now: Date { 36 | get { 37 | return _now 38 | } 39 | set (value) { 40 | _now = value 41 | // I'm going to go for just the easy approach; efficiency isn't a concern here. 42 | events.filter { $0.fireAt <= value } .forEach { self.enqueueAction($0.block) } 43 | events.removeAll(where: { $0.fireAt <= value}) 44 | listeners.forEach { $0(value) } 45 | } 46 | } 47 | 48 | var calendar: Calendar { 49 | get { 50 | var cal = Calendar.current 51 | cal.timeZone = timeZone 52 | return cal 53 | } 54 | } 55 | 56 | var timeZone: TimeZone { 57 | get { 58 | // Some time zone that isn't UTC or mine (America/New_York). 59 | // I'm picking +UTC so epoch isn't slightly-awkwardly right before midnight. 60 | return TimeZone(identifier: "Europe/Athens")! 61 | } 62 | } 63 | 64 | private func enqueueAction(_ block: @escaping () -> Void) { 65 | DispatchQueue.main.async(execute: block) 66 | } 67 | 68 | private struct WorkItem: CustomStringConvertible { 69 | let id: UUID 70 | let fireAt: Date 71 | let block: () -> Void 72 | 73 | var description: String { 74 | "\(fireAt): \(id)" 75 | } 76 | } 77 | 78 | private struct NoopScheduledItem: ScheduledTask { 79 | func cancel() { 80 | // nothing 81 | } 82 | } 83 | 84 | private struct ManualScheduledItem: ScheduledTask { 85 | let parent: ManualTickScheduler 86 | let id: UUID 87 | 88 | func cancel() { 89 | parent.events.removeAll(where: {$0.id == id}) 90 | } 91 | } 92 | } 93 | 94 | 95 | #endif 96 | -------------------------------------------------------------------------------- /whatdid/scheduling/OpenReason.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | enum OpenReason { 4 | case manual 5 | case scheduled 6 | 7 | var description: String { 8 | return String(describing: self) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /whatdid/scheduling/Scheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | protocol Scheduler { 6 | var now: Date { get } 7 | var timeZone: TimeZone { get } 8 | var calendar: Calendar { get } 9 | @discardableResult func schedule(_ description: String, at: Date, _ block: @escaping () -> Void) -> ScheduledTask 10 | } 11 | 12 | extension Scheduler { 13 | @discardableResult func schedule(_ description: String, after: TimeInterval, _ block: @escaping () -> Void) -> ScheduledTask { 14 | return schedule(description, at: now + after, block) 15 | } 16 | 17 | func timeInterval(since date: Date) -> TimeInterval { 18 | return now.timeIntervalSince(date) 19 | } 20 | } 21 | 22 | protocol ScheduledTask { 23 | func cancel() 24 | } 25 | 26 | extension Date { 27 | var timeIntervalSinceWhatdidNow: TimeInterval { 28 | return timeIntervalSince1970 - DefaultScheduler.instance.now.timeIntervalSince1970 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /whatdid/scheduling/SystemClockScheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class SystemClockScheduler: Scheduler { 6 | static let TOLERANCE_SECONDS : TimeInterval = 60 7 | private var pendingTasks = [UUID: SystemScheduledTask]() 8 | 9 | init() { 10 | NSWorkspace.shared.notificationCenter.addObserver( 11 | self, selector: #selector(handleWakeup(_:)), name: NSWorkspace.didWakeNotification, object: nil) 12 | } 13 | 14 | var now: Date { 15 | return Date() 16 | } 17 | 18 | var timeZone: TimeZone { 19 | return TimeZone.autoupdatingCurrent 20 | } 21 | 22 | var calendar: Calendar { 23 | return Calendar.current 24 | } 25 | 26 | func schedule(_ description: String, at date: Date, _ block: @escaping () -> Void) -> ScheduledTask { 27 | let task = SystemScheduledTask(description: description, at: date, block: block, parent: self) 28 | pendingTasks[task.uuid] = task 29 | task.reschedule() 30 | return task 31 | } 32 | 33 | @objc private func handleWakeup(_ notification: Notification) { 34 | wdlog(.debug, "Rescheduling %d task(s)", pendingTasks.count) 35 | pendingTasks.values.forEach {$0.reschedule()} 36 | } 37 | 38 | fileprivate func remove(_ task: SystemScheduledTask) { 39 | pendingTasks.removeValue(forKey: task.uuid) 40 | } 41 | 42 | fileprivate func isTaskStillActive(_ task: SystemScheduledTask) -> Bool { 43 | return pendingTasks.keys.contains(task.uuid) 44 | } 45 | 46 | var approximatePendingTasksCount: Int { 47 | return pendingTasks.count 48 | } 49 | 50 | fileprivate class SystemScheduledTask: ScheduledTask { 51 | fileprivate let uuid: UUID 52 | private let description: String 53 | private let deadline: Date 54 | private let parent: SystemClockScheduler 55 | private var block: (() -> Void)! 56 | private var workItem: DispatchWorkItem? 57 | 58 | init(description: String, at date: Date, block: @escaping () -> Void, parent: SystemClockScheduler) { 59 | self.description = description 60 | self.deadline = date 61 | self.block = block 62 | self.parent = parent 63 | self.uuid = UUID() 64 | } 65 | 66 | func cancel() { 67 | cancelWorkItem() 68 | completeSelf() 69 | } 70 | 71 | func reschedule() { 72 | cancelWorkItem() 73 | let timeLeft = deadline.timeIntervalSinceNow 74 | if timeLeft <= 0 { 75 | wdlog(.debug, "Running %{public}@ immediately", description) 76 | DispatchQueue.main.async(execute: runBlock) 77 | } else { 78 | wdlog(.debug, "Scheduling %{public}@ at %{public}@", description, deadline as NSDate) 79 | workItem = DispatchWorkItem(qos: .utility, block: runBlock) 80 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + timeLeft, execute: workItem!) 81 | } 82 | } 83 | 84 | func runBlock() { 85 | guard parent.isTaskStillActive(self) else { 86 | return 87 | } 88 | block() 89 | completeSelf() 90 | } 91 | 92 | private func cancelWorkItem() { 93 | if let active = workItem { 94 | active.cancel() 95 | } 96 | workItem = nil 97 | } 98 | 99 | private func completeSelf() { 100 | block = nil 101 | parent.remove(self) 102 | } 103 | } 104 | } 105 | 106 | extension DispatchWorkItem: ScheduledTask { 107 | // ScheduledTask.cancel() is already defined 108 | } 109 | -------------------------------------------------------------------------------- /whatdid/ui_testhook/PasteboardView.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class PasteboardView: WdView { 6 | 7 | private let pasteboardButton = NSButton(title: "", target: nil, action: nil) 8 | private let trashButton = ButtonWithClosure() 9 | private var pasteboard: NSPasteboard? 10 | var action: ((String) -> Void)? 11 | 12 | override func wdViewInit() { 13 | pasteboardButton.target = self 14 | pasteboardButton.action = #selector(self.handleButton) 15 | pasteboardButton.controlSize = .small 16 | pasteboardButton.font = NSFont.userFixedPitchFont(ofSize: NSFont.systemFontSize(for: .mini)) 17 | 18 | trashButton.bezelStyle = .regularSquare 19 | trashButton.title = "x" 20 | trashButton.alignment = .center 21 | trashButton.controlSize = pasteboardButton.controlSize 22 | trashButton.onPress {_ in 23 | self.setUp(pasteboard: nil) 24 | } 25 | 26 | setUp(pasteboard: nil) 27 | 28 | let box = NSStackView(orientation: .horizontal) 29 | box.alignment = .centerY 30 | box.addArrangedSubview(pasteboardButton) 31 | box.addArrangedSubview(trashButton) 32 | addSubview(box) 33 | box.anchorAllSides(to: self) 34 | } 35 | 36 | func copyStyle(to other: NSButton) { 37 | other.font = pasteboardButton.font 38 | other.controlSize = pasteboardButton.controlSize 39 | other.bezelStyle = pasteboardButton.bezelStyle 40 | } 41 | 42 | override func setAccessibilityLabel(_ accessibilityLabel: String?) { 43 | pasteboardButton.setAccessibilityLabel(accessibilityLabel) 44 | trashButton.setAccessibilityLabel(accessibilityLabel.map({$0 + "_rm"})) 45 | } 46 | 47 | @objc private func handleButton() { 48 | if let pasteboard = pasteboard { 49 | if let data = pasteboard.string(forType: .string) { 50 | action?(data) 51 | } else { 52 | wdlog(.warn, "no string for pasteboard %@", pasteboard.name.rawValue) 53 | } 54 | setUp(pasteboard: nil) 55 | } else { 56 | let new = NSPasteboard.withUniqueName() 57 | new.declareTypes([.string], owner: nil) 58 | wdlog(.debug, "created pasteboard: %@", new.name.rawValue) 59 | setUp(pasteboard: new) 60 | } 61 | } 62 | 63 | private func setUp(pasteboard: NSPasteboard?) { 64 | if let old = self.pasteboard { 65 | wdlog(.debug, "released pasteboard: %@", old.name.rawValue) 66 | old.releaseGlobally() 67 | } 68 | self.pasteboard = pasteboard 69 | if let pasteboard = pasteboard { 70 | pasteboardButton.title = pasteboard.name.rawValue 71 | trashButton.isEnabled = true 72 | } else { 73 | pasteboardButton.title = "generate pasteboard" 74 | trashButton.isEnabled = false 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /whatdid/ui_testhook/SampleData.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | #if UI_TEST 4 | import Foundation 5 | 6 | class SampleData { 7 | var onFail: ((String) -> Void) 8 | var now: Date 9 | var entryTransform: ((FlatEntry) -> FlatEntry)? = nil 10 | 11 | init(relativeTo date: Date, onFail: @escaping (String) -> Void) { 12 | self.now = date 13 | self.onFail = onFail 14 | } 15 | 16 | func entries() -> [FlatEntry] { 17 | let cal = Calendar.current 18 | let lastMidnight = cal.date(bySettingHour: 00, minute: 00, second: 00, of: now)! 19 | var lastEntryEnd: Date? = nil 20 | 21 | var nodes = [FlatEntry]() 22 | for line in readEntriesFile().split(separator: "\n") { 23 | /// The format is a backslash delimited line:: 24 | /// ``` 25 | /// | hh:mm | project | task | notes |` 26 | /// ``` 27 | /// Note that the `hh:mm` is _not_ tab-delimited; that uses a colon, so that it reads nicely. 28 | let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) 29 | if trimmed.isEmpty { 30 | continue 31 | } 32 | let segments = trimmed.split(separator: "|").map({$0.trimmingCharacters(in: .whitespaces)}) 33 | let hhmm = segments[0].split(separator: ":") 34 | let project = segments[1] 35 | let task = segments.maybe(2) ?? "" 36 | let notes = segments.maybe(3) ?? "" 37 | 38 | if hhmm.count != 2 { 39 | self.onFail("expected 2 segments, found \(hhmm.count): \(line)") 40 | } 41 | 42 | let hours = Int(hhmm[0])! 43 | let mins = Int(hhmm[1])! 44 | 45 | let endDate = cal.date(bySettingHour: hours, minute: mins, second: 0, of: lastMidnight)! 46 | let startDate = lastEntryEnd ?? endDate.addingTimeInterval(-300) 47 | nodes.append( 48 | FlatEntry( 49 | from: startDate, 50 | to: endDate, 51 | project: String(project), 52 | task: String(task), 53 | notes: String(notes))) 54 | lastEntryEnd = endDate 55 | } 56 | if let entryTransform = entryTransform { 57 | nodes = nodes.map(entryTransform) 58 | } 59 | return nodes 60 | } 61 | 62 | private func readEntriesFile() -> String { 63 | let bundle = Bundle(for: Swift.type(of: self)) 64 | guard let path = bundle.path(forResource: "screenshot-entries", ofType: "txt") else { 65 | return failAndReturn(with: "couldn't find resource") 66 | } 67 | guard let data = FileManager.default.contents(atPath: path) else { 68 | return failAndReturn(with: "no data in resource") 69 | } 70 | guard let string = String(data: data, encoding: .utf8) else { 71 | return failAndReturn(with: "invalid data in resource") 72 | } 73 | return string 74 | } 75 | 76 | private func failAndReturn(with message: String) -> T { 77 | let maybe: T? = nil 78 | onFail(message) 79 | return maybe! 80 | } 81 | } 82 | 83 | private extension Array { 84 | func maybe(_ index: Int) -> Element? { 85 | guard index >= 0 && index < count else { 86 | return nil 87 | } 88 | return self[index] 89 | } 90 | } 91 | 92 | #endif 93 | -------------------------------------------------------------------------------- /whatdid/ui_testhook/UITestCommonConsts.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | #if UI_TEST 6 | private let SUPPRESS_TUTORIAL_KEY = "SUPPRESS_TUTORIAL" 7 | private let SUPPRESS_TUTORIAL_VAL = "true" 8 | let SHOW_TUTORIAL_ON_FIRST_START = ProcessInfo.processInfo.environment[SUPPRESS_TUTORIAL_KEY] != SUPPRESS_TUTORIAL_VAL 9 | let SILENT_STARTUP = true 10 | 11 | func startupEnv(suppressTutorial: Bool) -> [String: String] { 12 | var env = [String: String]() 13 | if suppressTutorial { 14 | env[SUPPRESS_TUTORIAL_KEY] = SUPPRESS_TUTORIAL_VAL 15 | } 16 | return env 17 | } 18 | 19 | #else 20 | let SHOW_TUTORIAL_ON_FIRST_START = true 21 | let SILENT_STARTUP = false 22 | #endif 23 | -------------------------------------------------------------------------------- /whatdid/util/AnimationHelper.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | struct AnimationHelper { 6 | private init() {} 7 | 8 | #if UI_TEST 9 | static var animation_factor = 0.0 10 | #endif 11 | 12 | static func animate(duration: TimeInterval = 0.5, change: Action, onComplete: Action? = nil) { 13 | let actualDuration: TimeInterval 14 | #if UI_TEST 15 | actualDuration = duration * animation_factor 16 | #else 17 | actualDuration = duration 18 | #endif 19 | if actualDuration == 0 { 20 | change() 21 | onComplete?() 22 | } else { 23 | NSAnimationContext.runAnimationGroup( 24 | {context in 25 | context.allowsImplicitAnimation = true 26 | context.duration = actualDuration 27 | change() 28 | }, 29 | completionHandler: onComplete) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /whatdid/util/Atomic.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | @propertyWrapper struct Atomic : CustomStringConvertible { 6 | private let lock = NSLock() 7 | private var value : T 8 | 9 | init(wrappedValue: T) { 10 | self.value = wrappedValue 11 | } 12 | 13 | var wrappedValue: T { 14 | get { 15 | let local : T 16 | lock.lock() 17 | local = value 18 | lock.unlock() 19 | return local 20 | } 21 | set(value) { 22 | _ = getAndSet(value) 23 | } 24 | } 25 | 26 | var description: String { 27 | let valueStr = String(reflecting: wrappedValue) 28 | return "Atomic<\(valueStr)>" 29 | } 30 | 31 | mutating func getAndSet(_ newValue: T) -> T { 32 | return mapUnsafe({_ in newValue}).oldVal 33 | } 34 | 35 | /// Modifies the current value, and returns it 36 | mutating func modifyInPlace(_ block: (inout T) -> Void) { 37 | mapUnsafe {curr in 38 | block(&curr) 39 | return curr 40 | } 41 | } 42 | 43 | /// Maps the old value to a new one, and returns the new one 44 | @discardableResult mutating func mapAndGet(_ map: (T) -> T) -> T { 45 | return mapUnsafe({curr in 46 | map(curr) // "map" can't modify curr, so we know the mapUnsafe return value is the unchanged old value 47 | }).newVal 48 | } 49 | 50 | /// Maps the old value to a new one, and returns the old one 51 | @discardableResult mutating func map(_ map: (T) -> T) -> T { 52 | return mapUnsafe({curr in 53 | map(curr) // "map" can't modify curr, so we know the mapUnsafe return value is the unchanged old value 54 | }).oldVal 55 | } 56 | 57 | /// Performs a map, and returns the old value 58 | @discardableResult private mutating func mapUnsafe(_ map: (inout T) -> T) -> (oldVal: T, newVal: T) { 59 | lock.lock() 60 | let oldValue = value 61 | let newValue = map(&value) 62 | self.value = newValue 63 | lock.unlock() 64 | return (oldVal: oldValue, newVal: newValue) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /whatdid/util/DiagonalBoxFillHelper.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | struct DiagonalBoxFillHelper { 6 | /// The width of each individual stroke 7 | let strokeWidth: CGFloat 8 | /// The padding between strokes 9 | let strokePadding: CGFloat 10 | /// The angle, in degrees, of each stroke. This is measured as the angle between a vertical line segment and a stroke originating at the lower end 11 | /// of that segment. 12 | /// 13 | /// For example: 14 | /// ``` 15 | /// ──┬────── 16 | /// │ / 17 | /// │𝛼/ 𝛼 = strokeDegrees 18 | /// │/ 19 | /// ──┴──── 20 | /// ``` 21 | let strokeDegrees: CGFloat 22 | 23 | func diagonalLines(for dirtyRect: NSRect, within overallBounds: NSRect, draw: (LineSegment) -> Void) { 24 | guard strokeDegrees > 0 && strokeDegrees < 90 else { 25 | wdlog(.warn, "Couldn't draw strokes for angle %f because it has to be between 0 and 90 degrees") 26 | return 27 | } 28 | let actualDirty = overallBounds.intersection(dirtyRect) 29 | if actualDirty.isNull { 30 | return 31 | } 32 | /// SOH CAH TOA! 33 | /// Specifically, given the stroke angle `a`, `sin(a) = X / height`, where `X` is the offset we want. 34 | /// That means X = sin(a) * height 35 | let strokeRads = strokeDegrees * CGFloat.pi / 180 36 | let strokeOffset = sin(strokeRads) * overallBounds.height 37 | 38 | // Super dumb approach for now! 39 | // Start at X = (overallBounds.left - stroke). If we draw a stroke starting at that X and the overall bound's lower Y, such 40 | // a stroke would just barely hit the top-left of our overall bounds. 41 | // Keep going until X = (overallBounds.right + stroke): after that, lines are guaranteed to be outside the overall bounds. 42 | // Increment by padding. 43 | for strokeStartX in stride(from: overallBounds.minX - strokeOffset, through: overallBounds.maxX + strokeOffset, by: strokePadding) { 44 | if strokeStartX > actualDirty.maxX { 45 | // We've gone past the dirty rect; nothing more to do 46 | break 47 | } 48 | let strokeEndX = strokeStartX + strokeOffset 49 | if strokeEndX < actualDirty.minX { 50 | // We're not yet to the dirty rect; keep looping 51 | continue 52 | } 53 | let bottomLeft = NSPoint(x: strokeStartX, y: overallBounds.minY) 54 | let topRight = NSPoint(x: strokeEndX, y: overallBounds.maxY) 55 | // For now, don't even care about clipping. 56 | draw(LineSegment(a: bottomLeft, b: topRight)) 57 | } 58 | } 59 | 60 | func drawDiagonalLines(for dirtyRect: NSRect, within overallBounds: NSRect) { 61 | let oldDefaultLineWidth = NSBezierPath.defaultLineWidth 62 | defer { 63 | NSBezierPath.defaultLineWidth = oldDefaultLineWidth 64 | } 65 | NSBezierPath.defaultLineWidth = strokeWidth 66 | 67 | diagonalLines(for: dirtyRect, within: overallBounds) {line in 68 | NSBezierPath.strokeLine(from: line.a, to: line.b) 69 | } 70 | } 71 | 72 | struct LineSegment { 73 | let a: NSPoint 74 | let b: NSPoint 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /whatdid/util/Logger.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | import os 5 | 6 | #if UI_TEST 7 | var globalLogHook = LogHook(add: {_, _ in}, reset: {}) 8 | 9 | private func logToHook(_ type: OSLogType, _ message: String) { 10 | if Thread.current.isMainThread { 11 | globalLogHook.add(type, message) 12 | } else { 13 | DispatchQueue.main.async { 14 | globalLogHook.add(type, message) 15 | } 16 | } 17 | } 18 | 19 | struct LogHook { 20 | let add: (OSLogType, String) -> () 21 | let reset: Action 22 | } 23 | #endif 24 | 25 | // Note: we need all these dumb overloads because you can't pass an array into a vararg in Swift. 26 | // (or rather you can, but it comes through as a single element in the vararg). 27 | 28 | func wdlog(_ type: OSLogType, _ message: StaticString) { 29 | os_log(type, message) 30 | #if UI_TEST 31 | logToHook(type, s(message)) 32 | #endif 33 | } 34 | 35 | func wdlog(_ type: OSLogType, _ message: StaticString, _ arg0: CVarArg) { 36 | os_log(type, message, arg0) 37 | #if UI_TEST 38 | logToHook(type, String(format: s(message), arg0)) 39 | #endif 40 | } 41 | 42 | func wdlog(_ type: OSLogType, _ message: StaticString, _ arg0: CVarArg, _ arg1: CVarArg) { 43 | os_log(type, message, arg0, arg1) 44 | #if UI_TEST 45 | logToHook(type, String(format: s(message), arg0, arg1)) 46 | #endif 47 | } 48 | 49 | func wdlog(_ type: OSLogType, _ message: StaticString, _ arg0: CVarArg, _ arg1: CVarArg, _ arg2: CVarArg) { 50 | os_log(type, message, arg0, arg1, arg2) 51 | #if UI_TEST 52 | logToHook(type, String(format: s(message), arg0, arg1, arg2)) 53 | #endif 54 | } 55 | 56 | private func s(_ source: StaticString) -> String { 57 | return source.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } 58 | } 59 | -------------------------------------------------------------------------------- /whatdid/util/PushableString.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | class PushableString { 4 | private(set) var string = "" 5 | private var levels = [Int]() 6 | 7 | func with(_ other: String, run: () -> Void) { 8 | push(other) 9 | run() 10 | pop() 11 | } 12 | 13 | func push(_ other: String) { 14 | levels.append(other.count) 15 | string += other 16 | } 17 | 18 | func pop() { 19 | if let last = levels.popLast() { 20 | let sub = string.dropLast(last) 21 | string = String(sub) 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /whatdid/util/ScrollBarHelper.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | /// A class for helping you figure out when scroll bars are or aren't visible. 6 | /// This sets up notification handlers, so make sure to remove your reference to it when you're done! 7 | class ScrollBarHelper { 8 | private let observationRef: NSKeyValueObservation 9 | private let notificationRef: NSObjectProtocol 10 | 11 | /// Registers a listener on the given scroller. This will also invoke the listener once, with the current settings. 12 | /// 13 | /// `scroller`: the scroller to listen to 14 | /// 15 | /// `handler`: a block that takes a boolean of whether the scroller is visible. This takes into account both the scroller's 16 | /// intrinsic visibility (set by its NSScrollView) as well as `NSScroller.preferredScrollerStyle`. 17 | /// 18 | /// This init also registers a notification listener that will only be removed at deinit, so make sure to remove your reference 19 | /// to this instance when you're done with it. 20 | init(on scroller: NSScroller, handler: @escaping (CGFloat) -> Void) { 21 | func onOffHandler(_ isShown: Bool) { 22 | if isShown { 23 | let width = NSScroller.scrollerWidth(for: scroller.controlSize, scrollerStyle: scroller.scrollerStyle) 24 | handler(width) 25 | } else { 26 | handler(0) 27 | } 28 | } 29 | observationRef = scroller.observe( 30 | \NSScroller.isHidden, 31 | changeHandler: {scroller, _ in ScrollBarHelper.handle(on: scroller, onOffHandler) }) 32 | notificationRef = NotificationCenter.default.addObserver( 33 | forName: NSScroller.preferredScrollerStyleDidChangeNotification, 34 | object: nil, 35 | queue: OperationQueue.main, 36 | using: {_ in ScrollBarHelper.handle(on: scroller, onOffHandler)}) 37 | } 38 | 39 | private static func handle(on scroller: NSScroller, _ handler: @escaping (Bool) -> Void) { 40 | let currentStyle = NSScroller.preferredScrollerStyle 41 | let scrollShows: Bool 42 | switch currentStyle { 43 | case .legacy: 44 | scrollShows = true 45 | case .overlay: 46 | scrollShows = false 47 | @unknown default: 48 | wdlog(.warn, "uknown value for NSScroller.preferredScrollerStyle: %d", currentStyle.rawValue) 49 | scrollShows = true 50 | } 51 | handler(scrollShows && !scroller.isHidden) 52 | } 53 | 54 | deinit { 55 | NotificationCenter.default.removeObserver( 56 | notificationRef, 57 | name: NSScroller.preferredScrollerStyleDidChangeNotification, 58 | object: nil) 59 | // observationRef cleans itself 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /whatdid/util/SimpleRandom.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | class SimpleRandom { 6 | private var curr: UInt32 7 | 8 | init(seed: UInt32) { 9 | curr = seed 10 | _ = nextUInt32() 11 | } 12 | 13 | func nextUInt32() -> UInt32 { 14 | curr ^= curr << 6; 15 | curr ^= curr >> 21; // Java has >>> for signed ints 16 | curr ^= (curr << 7); 17 | return curr; 18 | } 19 | 20 | /// Returns a number from 0 to 1.0 21 | func nextUnitFloat() -> Float { 22 | return nextUInt32().asUnitFloat 23 | } 24 | 25 | func nextFloat(from: Float, to: Float) -> Float { 26 | guard from.isFinite && to.isFinite && from < to else { 27 | return Float.nan 28 | } 29 | let unitFloat = nextUnitFloat() 30 | return unitFloat * (from - to) + from 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /whatdid/util/SortedMap.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | /// A sorted map backed by a sorted array. 4 | /// 5 | /// This implementation is not as efficient as a self-balancing BST, but is simpler to implement. 6 | /// In particular, repeated single-adds are less efficient than a bulk add: each operation is `O(n log n)` on the current 7 | /// size of the map, meaning that `n` single additions are `O(n² log n)`. 8 | struct SortedMap { 9 | private var list = [Entry]() 10 | 11 | var entries: [Entry] { 12 | return list 13 | } 14 | 15 | private func indexOf(highestEntryLessThanOrEqualTo key: K) -> SearchResult { 16 | if list.isEmpty { 17 | return .emptySet 18 | } 19 | if key < list[0].key { 20 | return .noneFound 21 | } 22 | var low = 0 23 | var high = list.count - 1 24 | // A binary search traditionally loops while `low < high`, but we want to do `<=`. 25 | // This means the loop will go one iteration past the point at which we know that the element doesn't exist. 26 | // But when it ends, `high` and `low` will be correctly inverted such that the item would be "between" them. 27 | while low <= high { 28 | let mid = (high + low) / 2 29 | let midVal = list[mid].key 30 | if key == midVal { 31 | return .foundAtIndex(mid) 32 | } 33 | if key > midVal { 34 | low = mid + 1 35 | } else { 36 | high = mid - 1 37 | } 38 | } 39 | // The element isn't in the set, but `low` and `high` are the two indexes that it would have been between. 40 | // Note that they're inverted: low > high (since that's the condition that would cause the `while` to break). 41 | // We want to return the lower index, which is actually `high`. 42 | return .foundAtIndex(high) 43 | } 44 | 45 | func find(highestEntryLessThanOrEqualTo needle: K) -> V? { 46 | switch indexOf(highestEntryLessThanOrEqualTo: needle) { 47 | case .noneFound, .emptySet: 48 | return nil 49 | case .foundAtIndex(let idx): 50 | return list[idx].value 51 | } 52 | } 53 | 54 | mutating func add(kvPairs: [(K, V)]) { 55 | add(entries: kvPairs.map(Entry.init)) 56 | } 57 | 58 | mutating func add(entries: [Entry]) { 59 | list.append(contentsOf: entries) 60 | list.sort(by: {$0.key < $1.key }) 61 | // Save a copy of the list (which is already sorted), but then clear it. 62 | // Then, re-add all elements as long as they're unique 63 | let contents = list 64 | list.removeAll(keepingCapacity: true) 65 | for elem in contents { 66 | if elem.key != list.last?.key { 67 | list.append(elem) 68 | } 69 | } 70 | } 71 | 72 | mutating func removeAll() { 73 | list.removeAll() 74 | } 75 | 76 | enum SearchResult: Equatable { 77 | case emptySet 78 | case noneFound 79 | case foundAtIndex(_ index: Int) 80 | } 81 | 82 | struct Entry { 83 | let key: K 84 | let value: V 85 | } 86 | } 87 | 88 | extension SortedMap.Entry: Equatable where V: Equatable { 89 | 90 | } 91 | -------------------------------------------------------------------------------- /whatdid/util/StartupMessage.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | enum StartupMessage: Int { 4 | case updated 5 | 6 | var humanReadable: String { 7 | switch self { 8 | case .updated: 9 | return "Updated!" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /whatdid/util/SubsequenceMatcher.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | class SubsequenceMatcher { 6 | private init() { 7 | // nothing 8 | } 9 | 10 | static func matches(lookFor needle: T, inString haystack: T) -> [NSRange] { 11 | var needleChars = needle.lowercased().unicodeScalars.makeIterator() 12 | let haystackUnicodeScalars = haystack.lowercased().unicodeScalars 13 | var remainingHaystack = Substring.UnicodeScalarView(haystackUnicodeScalars) 14 | var foundIndices = [Int]() 15 | while let lookForChar = needleChars.next() { 16 | if let foundCharAt = remainingHaystack.firstIndex(of: lookForChar) { 17 | let offset = haystackUnicodeScalars.distance(from: haystackUnicodeScalars.startIndex, to: foundCharAt) 18 | foundIndices.append(offset) 19 | remainingHaystack = remainingHaystack.suffix(from: remainingHaystack.index(after: foundCharAt)) 20 | } else { 21 | return [] 22 | } 23 | } 24 | return NSRange.arrayFrom(ints: foundIndices) 25 | } 26 | 27 | static func hasMatches(lookFor needle: T, inString haystack: T) -> Bool { 28 | !matches(lookFor: needle, inString: haystack).isEmpty 29 | } 30 | 31 | struct Match: Equatable { 32 | let string: String 33 | let matchedRanges: [NSRange] 34 | 35 | init?(string: String, matchedRanges: [NSRange]) { 36 | if matchedRanges.isEmpty { 37 | return nil 38 | } else { 39 | for range in matchedRanges { 40 | if (range.location < 0) || (range.location + range.length > string.count) { 41 | wdlog(.error, "bad range: %{public}@ in string of length %d", range.description, string.count) 42 | return nil 43 | } 44 | } 45 | self.string = string 46 | self.matchedRanges = matchedRanges 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /whatdid/util/TypeAliases.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Foundation 4 | 5 | typealias Action = () -> Void 6 | -------------------------------------------------------------------------------- /whatdid/util/UpdateChannel.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | enum UpdateChannel: String { 4 | case alpha 5 | } 6 | -------------------------------------------------------------------------------- /whatdid/util/Version.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class Version: NSObject { 6 | static let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?.?.?" 7 | static let full = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?.?.?" 8 | static let gitSha = Bundle.main.infoDictionary?["ComYuvalShavitWtfdidVersion"] as? String ?? "???????" 9 | static let copyright = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? "Copyright 2020 Yuval Shavit" 10 | 11 | static var pretty : String { 12 | get { 13 | return "v\(short) (\(full) @\(gitSha))" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /whatdid/util/usagetracking/UsageAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsageAction.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 10/9/23. 6 | // Copyright © 2023 Yuval Shavit. All rights reserved. 7 | // 8 | 9 | enum UsageAction: String { 10 | case OpenEntryFormManually; 11 | case OpenReportManually; 12 | case OpenSettingsPane; 13 | case OpenReportInWindow; 14 | case OpenReportFromEntriesForm; 15 | case GlobalShortcut; 16 | } 17 | -------------------------------------------------------------------------------- /whatdid/util/usagetracking/UsageTrackingJsonDatum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsageTrackingJsonDatum.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 10/9/23. 6 | // Copyright © 2023 Yuval Shavit. All rights reserved. 7 | // 8 | 9 | struct UsageTrackingJsonDatum: Codable { 10 | let datumId: String 11 | let trackerId: String 12 | let action: String 13 | let epochMillis: Int64 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case datumId = "datum_id" 17 | case trackerId = "tracker_id" 18 | case action 19 | case epochMillis = "epoch_millis" 20 | } 21 | } 22 | 23 | extension UsageTrackingJsonDatum { 24 | init(from dto: UsageDatumDTO) { 25 | self.datumId = dto.datumId.uuidString 26 | self.trackerId = dto.trackerId.uuidString 27 | self.action = dto.action 28 | self.epochMillis = Int64(dto.timestamp.timeIntervalSince1970) * 1000 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /whatdid/views/ButtonWithClosure.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class ButtonWithClosure: NSButton { 6 | 7 | private var handlers = [(NSButton) -> Void]() 8 | 9 | convenience init(label: String, _ handler: @escaping (NSButton) -> Void) { 10 | self.init(title: label, target: nil, action: nil) 11 | onPress(handler) 12 | } 13 | 14 | override func sendAction(_ action: Selector?, to target: Any?) -> Bool { 15 | let result = super.sendAction(action, to: target) || (!handlers.isEmpty) 16 | handlers.forEach {handler in handler(self)} 17 | return result 18 | } 19 | 20 | func onPress(sendInitialState: Bool = false, _ handler: @escaping (NSButton) -> Void) { 21 | handlers.append(handler) 22 | if (sendInitialState) { 23 | handler(self) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /whatdid/views/ControllerDisplayView.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class ControllerDisplayView: NSBox { 6 | 7 | override init(frame frameRect: NSRect) { 8 | super.init(frame: frameRect) 9 | wdViewInit() 10 | } 11 | 12 | required init?(coder: NSCoder) { 13 | super.init(coder: coder) 14 | wdViewInit() 15 | } 16 | 17 | func wdViewInit() { 18 | borderWidth = 0 19 | cornerRadius = 0 20 | boxType = .custom 21 | titlePosition = .noTitle 22 | contentViewMargins = .zero 23 | } 24 | 25 | @IBOutlet 26 | weak var controllerToDisplay: NSViewController? { 27 | didSet { 28 | contentView = controllerToDisplay?.view 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /whatdid/views/DisclosureWithLabel.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | import SwiftUI 5 | 6 | class DisclosureWithLabel: WdView { 7 | 8 | private let disclosureButton = NSButton(title: "", target: nil, action: nil) 9 | private let labelButton = NSButton(title: "Show", target: nil, action: nil) 10 | private var _detailsView: NSView? 11 | private var _labelText = "" 12 | 13 | var onToggle: (Bool) -> Void = {_ in } 14 | 15 | var detailsView: NSView? { 16 | get { 17 | _detailsView 18 | } 19 | set (value) { 20 | _detailsView = value 21 | updateViews() 22 | } 23 | } 24 | 25 | var title: String { 26 | get { 27 | _labelText 28 | } 29 | set (value) { 30 | _labelText = value 31 | updateViews() 32 | } 33 | } 34 | 35 | var controlSize: NSControl.ControlSize { 36 | get { 37 | labelButton.controlSize 38 | } 39 | set (value) { 40 | labelButton.controlSize = value 41 | } 42 | } 43 | 44 | var isShowingDetails: Bool { 45 | get { 46 | disclosureButton.state == .on 47 | } 48 | set (value) { 49 | disclosureButton.state = value ? .on : .off 50 | updateViews() 51 | } 52 | } 53 | 54 | override func wdViewInit() { 55 | disclosureButton.bezelStyle = .disclosure 56 | disclosureButton.setButtonType(.pushOnPushOff) 57 | 58 | labelButton.isBordered = false 59 | labelButton.bezelStyle = .regularSquare 60 | labelButton.refusesFirstResponder = true 61 | [labelButton, disclosureButton].forEach {b in 62 | b.target = self 63 | b.action = #selector(handleClick(_:)) 64 | } 65 | 66 | let hstack = NSStackView(orientation: .horizontal) 67 | hstack.spacing = 2 68 | hstack.alignment = .centerY 69 | hstack.addArrangedSubview(disclosureButton) 70 | hstack.addArrangedSubview(labelButton) 71 | 72 | addSubview(hstack) 73 | hstack.anchorAllSides(to: self) 74 | } 75 | 76 | override func setAccessibilityIdentifier(_ accessibilityIdentifier: String?) { 77 | disclosureButton.setAccessibilityIdentifier(accessibilityIdentifier) 78 | } 79 | 80 | @objc private func handleClick(_ sender: NSButton) { 81 | if (sender == labelButton) { 82 | disclosureButton.performClick(nil) 83 | } 84 | updateViews() 85 | } 86 | 87 | private func updateViews() { 88 | let detailIsShowing = isShowingDetails 89 | let wasHidingDetails = detailsView?.isHidden ?? false 90 | detailsView?.isHidden = !detailIsShowing 91 | let verb = detailIsShowing ? "Hide" : "Show" 92 | labelButton.title = _labelText.isEmpty ? verb : "\(verb) \(_labelText)" 93 | 94 | onToggle(detailIsShowing) 95 | let isHidingDetails = detailsView?.isHidden ?? false 96 | // The onToggle must have re-flipped us. Make the toggle state reflects that. 97 | disclosureButton.state = isHidingDetails ? .off : .on 98 | 99 | if isHidingDetails != wasHidingDetails, let window = self.window, let contentView = window.contentView { 100 | // This is the expected case 101 | window.setContentSize(contentView.fittingSize) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /whatdid/views/FlippedView.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class FlippedView: WdView { 6 | 7 | override var isFlipped : Bool { 8 | get { 9 | return true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /whatdid/views/HrefButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HrefButton.swift 3 | // whatdid 4 | // 5 | // Created by Yuval Shavit on 10/9/23. 6 | // Copyright © 2023 Yuval Shavit. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class HrefButton: NSButton { 12 | 13 | override func sendAction(_ action: Selector?, to target: Any?) -> Bool { 14 | if let location = toolTip, let url = URL(string: location) { 15 | NSWorkspace.shared.open(url) 16 | } else { 17 | wdlog(.warn, "invalid href: %@", toolTip ?? "") 18 | } 19 | super.sendAction(action, to: target) 20 | return true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /whatdid/views/ProjectTaskFinder.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class ProjectTaskFinder: WdView { 6 | var onOpen: () -> (SaveState, [ProjectAndTask]) = {(SaveState.empty, [])} 7 | var previewSelect: (ProjectAndTask) -> Void = {_ in} 8 | var onSelect: (ProjectAndTask) -> Void = {_ in} 9 | var onCancel: (SaveState) -> Void = {_ in} 10 | 11 | private let autoCompleteField = AutoCompletingField() 12 | private var dismissButton: NSButton! 13 | private var saveState = SaveState.empty 14 | 15 | private static let separator = Character("\u{11}") 16 | 17 | override func wdViewInit() { 18 | dismissButton = ButtonWithClosure(label: "dismiss") {_ in self.cancel()} 19 | dismissButton.bezelStyle = .rounded 20 | if #available(macOS 11.0, *) { 21 | if let dismissImage = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: "dismiss") { 22 | dismissButton.image = dismissImage 23 | dismissButton.imagePosition = .imageOnly 24 | dismissButton.imageScaling = .scaleProportionallyDown 25 | dismissButton.isBordered = false 26 | } 27 | } 28 | 29 | autoCompleteField.accessibilityStringIgnoredChars = 30 | CharacterSet(charactersIn: "\(ProjectTaskFinder.separator)") 31 | autoCompleteField.optionsLookup = { 32 | let (saveState, options) = self.onOpen() 33 | self.autoCompleteField.stringValue = "" 34 | self.saveState = saveState 35 | let sep = ProjectTaskFinder.separator 36 | return options.map {pt in 37 | "\(sep)\(pt.project)\(sep) ▸ \(sep)\(pt.task)\(sep)" 38 | } 39 | } 40 | autoCompleteField.onAction = {f in 41 | self.parseProjectAndTask(to: self.onSelect) 42 | } 43 | autoCompleteField.onTextChange = { 44 | self.parseProjectAndTask(to: self.previewSelect) 45 | } 46 | autoCompleteField.onCancel = { 47 | self.cancel() 48 | return true // nothing left to do for next reponder(s) 49 | } 50 | autoCompleteField.tracksPopupSelection = true 51 | autoCompleteField.placeholderString = "enter any project or task" 52 | 53 | let stack = NSStackView( 54 | orientation: .horizontal, 55 | NSTextField(labelWithString: "find:"), 56 | autoCompleteField, 57 | dismissButton 58 | ) 59 | 60 | addSubview(stack) 61 | stack.anchorAllSides(to: self) 62 | } 63 | 64 | private func cancel() { 65 | onCancel(saveState) 66 | } 67 | 68 | override func setAccessibilityIdentifier(_ accessibilityIdentifier: String?) { 69 | autoCompleteField.setAccessibilityIdentifier(accessibilityIdentifier) 70 | dismissButton.setAccessibilityIdentifier(accessibilityIdentifier.map({"\($0)_cancel"})) 71 | } 72 | 73 | override func becomeFirstResponder() -> Bool { 74 | return autoCompleteField.becomeFirstResponder() 75 | } 76 | 77 | private func parseProjectAndTask(to handler: (ProjectAndTask) -> Void) { 78 | let str = autoCompleteField.stringValue 79 | let splits = str.split(separator: ProjectTaskFinder.separator) 80 | if splits.count >= 3 { 81 | /// The splits should be: 82 | /// `[project, " > ", task]` 83 | let pt = ProjectAndTask(project: String(splits[0]), task: splits.dropFirst(2).joined(separator: "")) 84 | handler(pt) 85 | } 86 | } 87 | 88 | struct SaveState { 89 | let project: String 90 | let task: String 91 | let notes: String 92 | 93 | static let empty = SaveState(project: "", task: "", notes: "") 94 | } 95 | } 96 | 97 | struct ProjectAndTask: Hashable { 98 | let project: String 99 | let task: String 100 | } 101 | 102 | extension ProjectAndTask { 103 | init(from entry: FlatEntry) { 104 | self.init(project: entry.project, task: entry.task) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /whatdid/views/WdView.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | @IBDesignable 6 | class WdView: NSView { 7 | override init(frame frameRect: NSRect) { 8 | super.init(frame: frameRect) 9 | wdViewInit() 10 | } 11 | 12 | required init?(coder: NSCoder) { 13 | super.init(coder: coder) 14 | wdViewInit() 15 | } 16 | 17 | override final func prepareForInterfaceBuilder() { 18 | wdViewInit() 19 | super.prepareForInterfaceBuilder() 20 | initializeInterfaceBuilder() 21 | invalidateIntrinsicContentSize() 22 | } 23 | 24 | /// Initializes the view, regardless of which `init` overload it started with. The default implementation does nothing, and you do not need to call `super`. 25 | func wdViewInit() { 26 | // nothing 27 | } 28 | 29 | /// Initializes the view for the interface builder. The default implementation does nothing, and you do not need to call `super`. 30 | func initializeInterfaceBuilder() { 31 | // nothing 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /whatdid/views/WhatdidTextField.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import Cocoa 4 | 5 | class WhatdidTextField: NSTextField { 6 | private var requestedWidth: CGFloat? 7 | 8 | override func becomeFirstResponder() -> Bool { 9 | let superSaysYes = super.becomeFirstResponder() 10 | if superSaysYes, let editor = currentEditor() { 11 | editor.perform(#selector(selectAll(_:)), with: self, afterDelay: 0) 12 | } 13 | return superSaysYes 14 | } 15 | 16 | override func textDidChange(_ notification: Notification) { 17 | super.textDidChange(notification) 18 | let frameHeight = frame.height 19 | let _ = stringValue // makes sure intrinsicHeightIncludingWrapping uses the up-to-date text 20 | if let desiredHeight = intrinsicHeightIncludingWrapping, desiredHeight != frameHeight { 21 | invalidateIntrinsicContentSize() 22 | } 23 | } 24 | 25 | override func setFrameSize(_ newSize: NSSize) { 26 | super.setFrameSize(newSize) 27 | requestNewSize(newSize) 28 | } 29 | 30 | override func setBoundsSize(_ newSize: NSSize) { 31 | super.setBoundsSize(newSize) 32 | requestNewSize(newSize) 33 | } 34 | 35 | override var intrinsicContentSize: NSSize { 36 | get { 37 | var superAdjusted = super.intrinsicContentSize 38 | if let adjustedHeight = intrinsicHeightIncludingWrapping { 39 | superAdjusted.height = adjustedHeight 40 | } 41 | return superAdjusted 42 | } 43 | } 44 | 45 | private func requestNewSize(_ newSize: NSSize) { 46 | if requestedWidth != newSize.width { 47 | requestedWidth = newSize.width 48 | invalidateIntrinsicContentSize() 49 | } 50 | } 51 | 52 | private var intrinsicHeightIncludingWrapping: CGFloat? { 53 | guard let cell = self.cell, let screen = window?.screen else { 54 | return nil 55 | } 56 | // I'm not sure why we need to shrink the width, but without it, the 57 | // field wraps one char later than it should. 58 | let widthShrink: CGFloat = isEditable ? 4.0 : 0.0 59 | let myBounds = bounds 60 | let myWidth = requestedWidth ?? myBounds.width 61 | let tallBounds = NSRect( 62 | x: myBounds.minX, 63 | y: myBounds.minY, 64 | width: myWidth - widthShrink, 65 | height: screen.frame.height) 66 | let adjustedHeight = cell.cellSize(forBounds: tallBounds) 67 | return adjustedHeight.height 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /whatdid/whatdid.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /whatdid/whatdidAppStore.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.temporary-exception.mach-lookup.global-name 12 | 13 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 14 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /whatdid/whatdidRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.temporary-exception.mach-lookup.global-name 12 | 13 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 14 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /whatdidTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /whatdidTests/controllers/ControllerTestBase.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class ControllerTestBase: XCTestCase { 7 | 8 | private(set) var thisMorning: Date! 9 | private var model: Model! 10 | 11 | override func setUpWithError() throws { 12 | thisMorning = TimeUtil.dateForTime(.previous, hh: 9, mm: 00) 13 | } 14 | 15 | func loadModel(withData dataLoader: (DataBuilder) -> Void) -> Model { 16 | let uniqueName = name.replacingOccurrences(of: "\\W", with: "", options: .regularExpression) 17 | model = Model(modelName: uniqueName, clearAllEntriesOnStartup: true) 18 | // fetch the (empty set of) entries, to force the model's lazy loading. Otherwise, the unit test's adding of entries can 19 | // race with the controller's fetching of them, such that they both try to clear out the same set of old files (and 20 | // whoever gets there second, fails due to those files not being there.) 21 | let _ = model.listEntries(from: thisMorning, to: DefaultScheduler.instance.now) 22 | 23 | let dataBuilder = DataBuilder(using: model, startingAt: thisMorning) 24 | dataLoader(dataBuilder) 25 | 26 | // Wait until we have as many entries as our DataBuilder expects 27 | let timeoutAt = Date().addingTimeInterval(3) 28 | while model.listEntries(from: Date.distantPast, to: Date.distantFuture).count < dataBuilder.expected { 29 | usleep(50000) 30 | XCTAssertLessThan(Date(), timeoutAt) 31 | } 32 | return model 33 | } 34 | 35 | func createController(withData dataLoader: (DataBuilder) -> Void) -> T { 36 | let _ = loadModel(withData: dataLoader) 37 | if let nib = NSNib(nibNamed: nibName, bundle: Bundle(for: T.self)) { 38 | var topLevelObjects: NSArray? = NSArray() 39 | nib.instantiate(withOwner: nil, topLevelObjects: &topLevelObjects) 40 | if let controller = topLevelObjects?.compactMap({$0 as? T}).first { 41 | controller.viewDidLoad() 42 | load(model: model, into: controller) 43 | return controller 44 | } else { 45 | XCTFail("couldn't find EntriesTreeController in nib") 46 | } 47 | } else { 48 | XCTFail("couldn't load nib") 49 | } 50 | fatalError() 51 | } 52 | 53 | func load(model: Model, into controller: T) { 54 | XCTFail("must override this method!") 55 | } 56 | 57 | var nibName: String { 58 | T.className().replacingOccurrences(of: "^.*\\.", with: "", options: .regularExpression) 59 | } 60 | 61 | class DataBuilder { 62 | private let model: Model 63 | private let startingAt: Date 64 | private var lastEventOffset = TimeInterval(0) 65 | private(set) var expected = 0 66 | /// An offset that's applied to all events (after you set this; it's not retroactive). 67 | var eventOffset = TimeInterval(0) 68 | 69 | init(using model: Model, startingAt: Date) { 70 | self.model = model 71 | self.startingAt = startingAt 72 | } 73 | 74 | func add(project: String, task: String, note: String, withDuration minutes: Int) { 75 | let thisTaskDuration = TimeInterval(minutes * 60) 76 | let from = startingAt.addingTimeInterval(lastEventOffset + eventOffset) 77 | let to = from.addingTimeInterval(thisTaskDuration) 78 | let entry = FlatEntry(from: from, to: to, project: project, task: task, notes: note) 79 | model.add(entry, andThen: {}) 80 | lastEventOffset += thisTaskDuration 81 | expected += 1 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /whatdidTests/controllers/PrefsGeneralPaneControllerTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class PrefsGeneralPaneControllerTest: XCTestCase { 7 | 8 | func testExportFormat() { 9 | /// Saturday, June 4, 2022 9:23:40 PM GMT-04:00 DST 10 | let date = Date(timeIntervalSince1970: 1654392220) 11 | let scheduler = DummyScheduler(now: date) 12 | let actual = PrefsGeneralPaneController.exportFileName(JsonEntryExportFormat(), scheduler) 13 | XCTAssertEqual("whatdid-export-2022-06-04T212340-0400.json", actual) 14 | } 15 | 16 | fileprivate struct DummyScheduler: Scheduler { 17 | func schedule(_ description: String, at: Date, _ block: @escaping () -> Void) -> ScheduledTask { 18 | fatalError("schedule() not implemented") 19 | } 20 | 21 | let now: Date 22 | let timeZone = TimeZone(identifier: "US/Eastern")! 23 | let calendar = Calendar(identifier: .gregorian) 24 | } 25 | } 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /whatdidTests/controllers/PtnViewControllerTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class PtnViewControllerTest: ControllerTestBase { 7 | 8 | func testFocusPreservesFields() { 9 | let (ptn, window) = openPtn { dataBuilder in 10 | dataBuilder.add(project: "my_proj", task: "my_task", note: "", withDuration: 60) 11 | } 12 | // initial state 13 | XCTAssertIdentical(ptn.projectField, autoCompletingField(containing: window.firstResponder)) 14 | XCTAssertTrue(ptn.projectField.popupIsOpen) 15 | XCTAssertEqual("", ptn.projectField.stringValue) 16 | XCTAssertFalse(ptn.taskField.popupIsOpen) 17 | XCTAssertEqual("", ptn.taskField.stringValue) 18 | 19 | sendEvents(simulateTyping: "hello\tworld", into: ptn.projectField) 20 | 21 | // after "hello⇥world": 22 | XCTAssertIdentical(ptn.taskField, autoCompletingField(containing: window.firstResponder)) 23 | XCTAssertFalse(ptn.projectField.popupIsOpen) 24 | XCTAssertEqual("hello", ptn.projectField.stringValue) 25 | XCTAssertTrue(ptn.taskField.popupIsOpen) 26 | XCTAssertEqual("world", ptn.taskField.stringValue) 27 | 28 | // make first "project" field first responder 29 | window.makeFirstResponder(ptn.projectField) 30 | XCTAssertIdentical(ptn.projectField, autoCompletingField(containing: window.firstResponder)) 31 | XCTAssertTrue(ptn.projectField.popupIsOpen) 32 | XCTAssertEqual("hello", ptn.projectField.stringValue) 33 | XCTAssertFalse(ptn.taskField.popupIsOpen) 34 | XCTAssertEqual("world", ptn.taskField.stringValue) 35 | } 36 | 37 | func openPtn(withData dataBuilder: (DataBuilder) -> Void) -> (PtnViewController, NSWindow) { 38 | AppDelegate.instance.mainMenu.open(.ptn, reason: .manual) 39 | guard let ptn = AppDelegate.instance.mainMenu.contentViewController as? PtnViewController else { 40 | XCTFail("main window controller was not a PtnViewController") 41 | fatalError() 42 | } 43 | ptn.override(model: loadModel(withData: dataBuilder)) 44 | ptn.grabFocusNow() 45 | return (ptn, AppDelegate.instance.mainMenu.window!) 46 | } 47 | 48 | func autoCompletingField(containing responder: NSResponder?) -> AutoCompletingField? { 49 | var view = responder as? NSView 50 | while view != nil { 51 | if let result = view as? AutoCompletingField { 52 | return result 53 | } 54 | view = view?.superview 55 | } 56 | return nil 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /whatdidTests/extensions/NSRange+HelpersTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class NSRange_HelpersTest: XCTestCase { 7 | 8 | func testEmpty() { 9 | XCTAssertEqual( 10 | [], 11 | NSRange.arrayFrom(ints: [])) 12 | } 13 | 14 | func testOne() { 15 | XCTAssertEqual( 16 | [NSRange(location: 1, length: 1)], 17 | NSRange.arrayFrom(ints: [1])) 18 | } 19 | 20 | func testSparse() { 21 | XCTAssertEqual( 22 | [NSRange(location: 1, length: 1), NSRange(location: 3, length: 1)], 23 | NSRange.arrayFrom(ints: [1, 3])) 24 | } 25 | 26 | func testSequential() { 27 | XCTAssertEqual( 28 | [NSRange(location: 1, length: 3)], 29 | NSRange.arrayFrom(ints: [1, 2, 3])) 30 | } 31 | 32 | /// Not really different arrayFrom sparse + sequential, but eh, why not 33 | func testMixed() { 34 | XCTAssertEqual( 35 | [NSRange(location: 1, length: 2), NSRange(location: 5, length: 1)], 36 | NSRange.arrayFrom(ints: [1, 2, 5])) 37 | } 38 | 39 | func testAllDuplicateNumbers() { 40 | XCTAssertEqual( 41 | [NSRange(location: 1, length: 1)], 42 | NSRange.arrayFrom(ints: [1, 1, 1])) 43 | } 44 | 45 | func testDuplicateNumbersThenSequential() { 46 | XCTAssertEqual( 47 | [NSRange(location: 1, length: 2)], 48 | NSRange.arrayFrom(ints: [1, 1, 2])) 49 | } 50 | 51 | func testDuplicateNumbersThenSparse() { 52 | XCTAssertEqual( 53 | [NSRange(location: 1, length: 1), NSRange(location: 3, length: 1)], 54 | NSRange.arrayFrom(ints: [1, 1, 3])) 55 | } 56 | 57 | func testNumbersOutOfOrder() { 58 | XCTAssertEqual( 59 | [NSRange(location: 1, length: 3)], 60 | NSRange.arrayFrom(ints: [2, 3, 1])) 61 | } 62 | 63 | // Not expected, but should work anyway 64 | func testNegatives() { 65 | XCTAssertEqual( 66 | [NSRange(location: -2, length: 4), NSRange(location: 3, length: 1)], 67 | NSRange.arrayFrom(ints: [-2, -1, 0, 1, 3])) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /whatdidTests/extensions/Unit32+HelpersTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | 5 | class Unit32_HelpersTest: XCTestCase { 6 | 7 | func test0() { 8 | check(that: 0, equals: 0.0) 9 | } 10 | 11 | func testMax() { 12 | check(that: UInt32.max, equals: 1.0) 13 | } 14 | 15 | func testHalf() { 16 | check(that: UInt32.max / 2, equals: 0.5) 17 | } 18 | 19 | private func check(that asUnit: UInt32, equals expected: Float) { 20 | let actual = asUnit.asUnitFloat 21 | XCTAssertEqual(expected, actual) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /whatdidTests/model/EntryExportFormatsTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class EntryExportFormatsTest: XCTestCase { 7 | 8 | private let entries = [ 9 | FlatEntry(from: hour(0), to: hour(1), project: "main project", task: "first task", notes: "my notes"), 10 | FlatEntry(from: hour(2), to: hour(3), project: "main project", task: "first task", notes: nil), 11 | FlatEntry(from: hour(4), to: hour(5), project: "main project", task: "second task", notes: ""), 12 | FlatEntry(from: hour(6), to: hour(7), project: "second project", task: "third task", notes: "last notes"), 13 | ].shuffled() // order shouldn't matter 14 | 15 | func testCsv() throws { 16 | let result = try entriesToString(using: CsvEntryExportFormat()) 17 | let expected = lines( 18 | "start_time,end_time,project,task,notes", 19 | q("'1970-01-01T00:00:00Z','1970-01-01T01:00:00Z','main project','first task','my notes'"), 20 | q("'1970-01-01T02:00:00Z','1970-01-01T03:00:00Z','main project','first task',"), 21 | q("'1970-01-01T04:00:00Z','1970-01-01T05:00:00Z','main project','second task',"), 22 | q("'1970-01-01T06:00:00Z','1970-01-01T07:00:00Z','second project','third task','last notes'") 23 | ) 24 | XCTAssertEqual(expected, result) 25 | } 26 | 27 | func testJson() throws { 28 | let result = try entriesToString(using: JsonEntryExportFormat()) 29 | typealias Project = String 30 | typealias Task = String 31 | typealias Entry = [String : String] 32 | typealias ExportStructure = [Project : [Task : [Entry]]] 33 | let expected = [ 34 | "main project": [ 35 | "first task": [ 36 | ["from":"1970-01-01T00:00:00Z","to":"1970-01-01T01:00:00Z","notes":"my notes"], 37 | ["from":"1970-01-01T02:00:00Z","to":"1970-01-01T03:00:00Z","notes":""] 38 | ], 39 | "second task": [ 40 | ["from":"1970-01-01T04:00:00Z","to":"1970-01-01T05:00:00Z","notes":""] 41 | ] 42 | ], 43 | "second project": [ 44 | "third task": [ 45 | ["from":"1970-01-01T06:00:00Z","to":"1970-01-01T07:00:00Z","notes":"last notes"] 46 | ] 47 | ] 48 | ] 49 | let json = JSONDecoder() 50 | let resultAsJson = try json.decode(ExportStructure.self, from: result.data(using: .utf8)!) 51 | XCTAssertEqual(expected, resultAsJson) 52 | } 53 | 54 | func testTree() throws { 55 | let result = try entriesToString(using: TextTreeEntryExportFormat()) 56 | let expected = lines( 57 | "Total time: 4h 0m", 58 | " 75.0% (3h 0m): main project", 59 | " 50.0% (2h 0m): first task", 60 | " 25.0% (1h 0m): my notes", 61 | " 25.0% (1h 0m): (no notes entered)", 62 | " 25.0% (1h 0m): second task", 63 | " 25.0% (1h 0m): (no notes entered)", 64 | " 25.0% (1h 0m): second project", 65 | " 25.0% (1h 0m): third task", 66 | " 25.0% (1h 0m): last notes") 67 | XCTAssertEqual(expected, result) 68 | } 69 | 70 | private func entriesToString(using format: EntryExportFormat) throws -> String { 71 | let buffer = OutputStream(toMemory: ()) 72 | buffer.open() 73 | try format.write(entries: entries, to: buffer) 74 | buffer.close() 75 | let data = buffer.property(forKey: .dataWrittenToMemoryStreamKey) as! Data 76 | return String(data: data, encoding: .utf8)! 77 | } 78 | } 79 | 80 | private func hour(_ hour: Int) -> Date { 81 | return Date(timeIntervalSince1970: TimeInterval(hour) * 60.0 * 60.0) 82 | } 83 | 84 | /// Joins all of the given strings via a newline char, and adds one more newline at the end. 85 | private func lines(_ lines: String...) -> String { 86 | return lines.joined(separator: "\n") + "\n" 87 | } 88 | 89 | /// Convert all single-quotes (`'`) to double quotes (`"`). 90 | /// 91 | /// This just makes it nicer to add double quotes without having to escape them in the source. 92 | func q(_ string: String) -> String { 93 | return string.replacingOccurrences(of: "'", with: "\"") 94 | } 95 | -------------------------------------------------------------------------------- /whatdidTests/scheduling/DelegatingSchedulerTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class DelegatingSchedulerTest: XCTestCase { 7 | 8 | func testSingleTaskCanceled() { 9 | let dummy = DummyScheduler() 10 | let delegate = DelegatingScheduler(delegateTo: dummy) 11 | var count = 0 12 | 13 | let task = delegate.schedule("", after: 0) { count += 1 } 14 | XCTAssertEqual(1, delegate.tasksCount) 15 | XCTAssertEqual(0, count) 16 | 17 | task.cancel() 18 | XCTAssertEqual(0, delegate.tasksCount) 19 | XCTAssertEqual(0, count) 20 | 21 | dummy.runAllScheduled() 22 | XCTAssertEqual(0, count) 23 | } 24 | 25 | func testAllTasksCanceled() { 26 | let dummy = DummyScheduler() 27 | let delegate = DelegatingScheduler(delegateTo: dummy) 28 | var count = 0 29 | 30 | delegate.schedule("", after: 0) { count += 1 } 31 | XCTAssertEqual(1, delegate.tasksCount) 32 | XCTAssertEqual(0, count) 33 | 34 | delegate.close() 35 | XCTAssertEqual(0, delegate.tasksCount) 36 | XCTAssertEqual(0, count) 37 | 38 | dummy.runAllScheduled() 39 | XCTAssertEqual(0, count) 40 | } 41 | 42 | func testClosedSchedulerIgnoresNewTasks() { 43 | let dummy = DummyScheduler() 44 | let delegate = DelegatingScheduler(delegateTo: dummy) 45 | var count = 0 46 | 47 | delegate.close() 48 | XCTAssertEqual(0, delegate.tasksCount) 49 | XCTAssertEqual(0, count) 50 | 51 | delegate.schedule("", after: 0) { count += 1 } 52 | XCTAssertEqual(0, delegate.tasksCount) 53 | XCTAssertEqual(0, count) 54 | 55 | dummy.runAllScheduled() 56 | XCTAssertEqual(0, delegate.tasksCount) 57 | XCTAssertEqual(0, count) 58 | } 59 | 60 | func testSingleTaskRuns() { 61 | let dummy = DummyScheduler() 62 | let delegate = DelegatingScheduler(delegateTo: dummy) 63 | var count = 0 64 | 65 | delegate.schedule("", after: 0) { count += 1 } 66 | XCTAssertEqual(1, delegate.tasksCount) 67 | XCTAssertEqual(0, count) 68 | 69 | dummy.runAllScheduled() 70 | XCTAssertEqual(0, delegate.tasksCount) 71 | XCTAssertEqual(1, count) 72 | } 73 | 74 | func testSingleTaskRunsThenIsCanceled() { 75 | let dummy = DummyScheduler() 76 | let delegate = DelegatingScheduler(delegateTo: dummy) 77 | var count = 0 78 | 79 | let task = delegate.schedule("", after: 0) { count += 1 } 80 | XCTAssertEqual(1, delegate.tasksCount) 81 | XCTAssertEqual(0, count) 82 | 83 | dummy.runAllScheduled() 84 | XCTAssertEqual(0, delegate.tasksCount) 85 | XCTAssertEqual(1, count) 86 | 87 | task.cancel() // should be noop 88 | dummy.runAllScheduled() 89 | XCTAssertEqual(0, delegate.tasksCount) 90 | XCTAssertEqual(1, count) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /whatdidTests/scheduling/DummyScheduler.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import Cocoa 4 | @testable import Whatdid 5 | 6 | /// A Scheduler that doesn't depend on any system clock. 7 | /// Instead, its tasks get enqueued until you manually run them via `runAllScheduled`. Useful for unit tests involving schedulers. 8 | class DummyScheduler: Scheduler { 9 | private var tasks = [DummyScheduledTask]() 10 | let now = Date() 11 | let timeZone = TimeZone.current 12 | let calendar = Calendar.current 13 | 14 | func runAllScheduled() { 15 | for task in tasks { 16 | if !task.isCanceled && !task.hasRun { 17 | task.hasRun = true 18 | task.block() 19 | } 20 | } 21 | } 22 | 23 | func schedule(_ description: String, at: Date, _ block: @escaping () -> Void) -> ScheduledTask { 24 | return add(block) 25 | } 26 | 27 | private func add(_ block: @escaping () -> Void) -> ScheduledTask { 28 | let task = DummyScheduledTask(block) 29 | tasks.append(task) 30 | return task 31 | } 32 | 33 | class DummyScheduledTask: ScheduledTask { 34 | let block: () -> Void 35 | var hasRun = false 36 | var isCanceled = false 37 | 38 | init(_ block: @escaping () -> Void) { 39 | self.block = block 40 | } 41 | 42 | func cancel() { 43 | isCanceled = true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /whatdidTests/test_helpers/ExtraAssertions.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | 5 | func XCTAssertEqualIgnoringOrder(_ expression1: [T], _ expression2: [T]) where T : Hashable { 6 | let set1 = Set(expression1) 7 | let set2 = Set(expression2) 8 | XCTAssertEqual(set1, set2) 9 | } 10 | -------------------------------------------------------------------------------- /whatdidTests/test_helpers/NSEventHelpers.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | func sendEvents(simulateTyping string: String, into view: NSView) { 7 | for event in createEvents(simulateTyping: string, into: view) { 8 | NSApp.sendEvent(event) 9 | } 10 | } 11 | 12 | func createEvents(simulateTyping string: String, into view: NSView, withModifiers mods: NSEvent.ModifierFlags = []) -> [NSEvent] { 13 | guard let window = view.window else { 14 | wdlog(.error, "no window") 15 | return [] 16 | } 17 | var results = [NSEvent]() 18 | let viewFrame = view.frame 19 | let viewFrameMid = NSPoint(x: viewFrame.midX, y: viewFrame.midY) 20 | let windowPoint = view.convert(viewFrameMid, to: nil) 21 | for char in string { 22 | guard let keyCode = charMap[char] else { 23 | wdlog(.error, "couldn't get key code for: %@", String(char)) 24 | return [] 25 | } 26 | var lcChar = char 27 | var charMods = NSEvent.ModifierFlags(arrayLiteral: mods) 28 | if char.isUppercase { 29 | lcChar = char.lowercased().first! 30 | charMods.insert(.shift) 31 | } 32 | for eventType in [NSEvent.EventType.keyDown, NSEvent.EventType.keyUp] { 33 | guard let event = NSEvent.keyEvent( 34 | with: eventType, 35 | location: windowPoint, 36 | modifierFlags: charMods, 37 | timestamp: ProcessInfo.processInfo.systemUptime, 38 | windowNumber: window.windowNumber, 39 | context: nil, 40 | characters: String(char), 41 | charactersIgnoringModifiers: String(lcChar), 42 | isARepeat: false, 43 | keyCode: keyCode) 44 | else { 45 | wdlog(.error, "couldn't generate event") 46 | return [] 47 | } 48 | results.append(event) 49 | } 50 | } 51 | return results 52 | } 53 | 54 | fileprivate let charMap: [Character: UInt16] = [ 55 | "0" : 0x1D, 56 | "1" : 0x12, 57 | "2" : 0x13, 58 | "3" : 0x14, 59 | "4" : 0x15, 60 | "5" : 0x17, 61 | "6" : 0x16, 62 | "7" : 0x1A, 63 | "8" : 0x1C, 64 | "9" : 0x19, 65 | "a" : 0x00, 66 | "b" : 0x0B, 67 | "c" : 0x08, 68 | "d" : 0x02, 69 | "e" : 0x0E, 70 | "f" : 0x03, 71 | "g" : 0x05, 72 | "h" : 0x04, 73 | "i" : 0x22, 74 | "j" : 0x26, 75 | "k" : 0x28, 76 | "l" : 0x25, 77 | "m" : 0x2E, 78 | "n" : 0x2D, 79 | "o" : 0x1F, 80 | "p" : 0x23, 81 | "q" : 0x0C, 82 | "r" : 0x0F, 83 | "s" : 0x01, 84 | "t" : 0x11, 85 | "u" : 0x20, 86 | "v" : 0x09, 87 | "w" : 0x0D, 88 | "x" : 0x07, 89 | "y" : 0x10, 90 | "z" : 0x06, 91 | "\\" : 0x2A, 92 | "," : 0x2B, 93 | "=" : 0x18, 94 | "`" : 0x32, 95 | "[" : 0x21, 96 | "-" : 0x1B, 97 | "." : 0x2F, 98 | "\"" : 0x27, 99 | "]" : 0x1E, 100 | ";" : 0x29, 101 | "/" : 0x2C, 102 | "\r" : 0x24, 103 | " " : 0x31, 104 | "\t" : 0x30, 105 | ] 106 | -------------------------------------------------------------------------------- /whatdidTests/util/PrefsTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class PrefsTest: XCTestCase { 7 | 8 | func testEncodingBackAndForthNormal() { 9 | let hhmmOrig = HoursAndMinutes(hours: 12, minutes: 30) 10 | let hhmmBack = HoursAndMinutes(encoded: hhmmOrig.encoded) 11 | check(actual: hhmmBack, expectedHours: 12, expectedMinutes: 30) 12 | } 13 | 14 | func testEncodingNegativeAndBig() { 15 | let hhmmOrig = HoursAndMinutes(hours: -543, minutes: 21) 16 | let hhmmBack = HoursAndMinutes(encoded: hhmmOrig.encoded) 17 | check(actual: hhmmBack, expectedHours: -543, expectedMinutes: 21) 18 | } 19 | 20 | func testMinutesTooBig() { 21 | let hhmmOrig = HoursAndMinutes(hours: 1, minutes: 60) 22 | let hhmmBack = HoursAndMinutes(encoded: hhmmOrig.encoded) 23 | check(actual: hhmmBack, expectedHours: 1, expectedMinutes: 0) 24 | } 25 | 26 | private func check(actual: HoursAndMinutes, expectedHours: Int, expectedMinutes: Int) { 27 | var actualHours: Int? 28 | var actualMinutes: Int? 29 | actual.read() {hh, mm in 30 | actualHours = hh 31 | actualMinutes = mm 32 | } 33 | XCTAssertEqual(expectedHours, actualHours) 34 | XCTAssertEqual(expectedMinutes, actualMinutes) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /whatdidTests/util/SortedMapTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class SortedMapTest: XCTestCase { 7 | 8 | typealias E = SortedMap.Entry 9 | private let e = SortedMap.Entry.init 10 | 11 | func testEmptySet() { 12 | let s = SortedMap() 13 | XCTAssertEqual(nil, s.find(highestEntryLessThanOrEqualTo: -1.0)) 14 | } 15 | 16 | func testNeedleLessThanLowest() { 17 | var s = SortedMap() 18 | s.add(kvPairs: [(0, "a"), (1.1, "b"), (2.2, "c"), (3.3, "d"), (4.4, "e")]) 19 | XCTAssertEqual(nil, s.find(highestEntryLessThanOrEqualTo: -1.0)) 20 | } 21 | 22 | func testEmptyAdd() { 23 | var s = SortedMap() 24 | XCTAssertTrue(s.entries.isEmpty) 25 | s.add(entries: []) 26 | XCTAssertTrue(s.entries.isEmpty) 27 | } 28 | 29 | func testSimpleOddLength() { 30 | var s = SortedMap() 31 | s.add(kvPairs: [(0, "a"), (1.1, "b"), (2.2, "c"), (3.3, "d"), (4.4, "e")]) 32 | XCTAssertEqual([e(0, "a"), e(1.1, "b"), e(2.2, "c"), e(3.3, "d"), e(4.4, "e")], s.entries) 33 | 34 | XCTAssertEqual("a", s.find(highestEntryLessThanOrEqualTo: 0)) 35 | XCTAssertEqual("a", s.find(highestEntryLessThanOrEqualTo: 1.0)) 36 | 37 | XCTAssertEqual("b", s.find(highestEntryLessThanOrEqualTo: 1.1)) 38 | XCTAssertEqual("b", s.find(highestEntryLessThanOrEqualTo: 1.2)) 39 | 40 | XCTAssertEqual("c", s.find(highestEntryLessThanOrEqualTo: 2.2)) 41 | XCTAssertEqual("c", s.find(highestEntryLessThanOrEqualTo: 2.3)) 42 | 43 | XCTAssertEqual("d", s.find(highestEntryLessThanOrEqualTo: 3.3)) 44 | XCTAssertEqual("d", s.find(highestEntryLessThanOrEqualTo: 3.4)) 45 | 46 | XCTAssertEqual("e", s.find(highestEntryLessThanOrEqualTo: 4.4)) 47 | XCTAssertEqual("e", s.find(highestEntryLessThanOrEqualTo: 4.5)) 48 | } 49 | 50 | func testSimpleEvenLength() { 51 | var s = SortedMap() 52 | s.add(kvPairs: [(0, "a"), (1.1, "b"), (2.2, "c"), (3.3, "d")]) 53 | XCTAssertEqual([e(0, "a"), e(1.1, "b"), e(2.2, "c"), e(3.3, "d")], s.entries) 54 | 55 | XCTAssertEqual("a", s.find(highestEntryLessThanOrEqualTo: 0)) 56 | XCTAssertEqual("a", s.find(highestEntryLessThanOrEqualTo: 1.0)) 57 | 58 | XCTAssertEqual("b", s.find(highestEntryLessThanOrEqualTo: 1.1)) 59 | XCTAssertEqual("b", s.find(highestEntryLessThanOrEqualTo: 1.2)) 60 | 61 | XCTAssertEqual("c", s.find(highestEntryLessThanOrEqualTo: 2.2)) 62 | XCTAssertEqual("c", s.find(highestEntryLessThanOrEqualTo: 2.3)) 63 | 64 | XCTAssertEqual("d", s.find(highestEntryLessThanOrEqualTo: 3.3)) 65 | XCTAssertEqual("d", s.find(highestEntryLessThanOrEqualTo: 3.4)) 66 | } 67 | 68 | func testInsertInOrder() { 69 | var s = SortedMap() 70 | s.add(kvPairs: [(0.0, "a"), (1.1, "b"), (2.2, "c")]) 71 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 72 | } 73 | 74 | func testInsertReverseOrder() { 75 | var s = SortedMap() 76 | s.add(kvPairs: [(2.2, "c"), (1.1, "b"), (0.0, "a"), ]) 77 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 78 | } 79 | 80 | func testInsertMixedOrder() { 81 | var s = SortedMap() 82 | s.add(kvPairs: [(1.1, "b"), (2.2, "c"), (0.0, "a"), ]) 83 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 84 | } 85 | 86 | func testInsertDuplicate() { 87 | var s = SortedMap() 88 | s.add(kvPairs: [(2.2, "c"), (1.1, "b"), (0.0, "a"), (1.1, "b"), (2.2, "c")]) 89 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 90 | } 91 | 92 | func testInsertOneAtATime() { 93 | var s = SortedMap() 94 | for i in [0, 1.1, 2.2] { 95 | s.add(entries: [e(Float(i), "v=\(i)")]) 96 | } 97 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 98 | XCTAssertEqual(["v=0.0", "v=1.1", "v=2.2"], s.entries.map({$0.value})) 99 | } 100 | 101 | func testRemoveAll() { 102 | var s = SortedMap() 103 | s.add(kvPairs: [(2.2, "c"), (1.1, "b"), (0.0, "a"), (1.1, "b"), (2.2, "c")]) 104 | XCTAssertEqual([0, 1.1, 2.2], s.entries.map({$0.key})) 105 | s.removeAll() 106 | XCTAssertEqual([], s.entries) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /whatdidTests/util/SubsequenceMatcherTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class SubsequenceMatcherTest: XCTestCase { 7 | typealias Match = SubsequenceMatcher.Match 8 | 9 | func testUnicodePosition() { 10 | checkOne(lookFor: "is", inString: "the 😺 is happy", expect: [NSRange(location: 6, length: 2)]) 11 | } 12 | 13 | func testUnicodeLength() { 14 | checkOne(lookFor: "😺", inString: "the 😺 is happy", expect: [NSRange(location: 4, length: 1)]) 15 | } 16 | 17 | func testJustNeedleEmpty() { 18 | checkOne(lookFor: "", inString: "foo", expect: []) 19 | } 20 | 21 | func testJustLookInEmpty() { 22 | checkOne(lookFor: "a", inString: "", expect: []) 23 | } 24 | 25 | func testBothEmpty() { 26 | checkOne(lookFor: "", inString: "", expect: []) 27 | } 28 | 29 | func testNeedleMatches() { 30 | checkOne(lookFor: "fizz", inString: "for is zz top", expect: [ 31 | NSRange(location: 0, length: 1), // f 32 | NSRange(location: 4, length: 1), // i 33 | NSRange(location: 7, length: 2) // zz 34 | ]) 35 | } 36 | 37 | func testNeedleDoesNotMatch() { 38 | checkOne(lookFor: "far", inString: "an f comes further", expect: []) 39 | } 40 | 41 | func testCaseInsensitivityWithLowercaseNeedle() { 42 | checkOne(lookFor: "lower", inString: "THE LOWER", expect: [NSRange(location: 4, length: 5)]) 43 | } 44 | 45 | func testCaseInsensitivityWithUppercaseNeedle() { 46 | checkOne(lookFor: "UPPER", inString: "the upper", expect: [NSRange(location: 4, length: 5)]) 47 | } 48 | 49 | func checkOne(lookFor needle: String, inString haystack: String, expect expected: [NSRange]) { 50 | XCTAssertEqual(SubsequenceMatcher.matches(lookFor: needle, inString: haystack), expected) 51 | } 52 | 53 | func match(for string: String, _ ranges: NSRange...) -> Match { 54 | return Match(string: string, matchedRanges: ranges)! 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /whatdidTests/views/SegmentedTimelineViewTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class SegmentedTimelineViewTest: XCTestCase { 7 | private let view = SegmentedTimelineView() 8 | private var seenEvents = [Event]() 9 | 10 | override func setUp() { 11 | view.onEnter = { self.seenEvents.append(.entered($0)) } 12 | view.onExit = { self.seenEvents.append(.exited($0)) } 13 | } 14 | 15 | func testNewlyInitialized() { 16 | XCTAssertEqual(strings(), view.highlightedProjects) 17 | } 18 | 19 | func testEventsEnterFiresFirst() { 20 | view.mouseEntered(with: MockEvent(affecting: "projectA")) 21 | XCTAssertEqual(strings("projectA"), view.highlightedProjects) 22 | checkEvents(expecting: .entered("projectA")) 23 | 24 | // First we'll fire an "enter" for projectB, and then an "exit" for projectA. 25 | view.mouseEntered(with: MockEvent(affecting: "projectB")) 26 | XCTAssertEqual(strings("projectB"), view.highlightedProjects) 27 | view.mouseExited(with: MockEvent(affecting: "projectA")) 28 | XCTAssertEqual(strings("projectB"), view.highlightedProjects) 29 | 30 | checkEvents(expecting: .exited("projectA"), .entered("projectB")) 31 | } 32 | 33 | func testEventsExitFiresFirst() { 34 | view.mouseEntered(with: MockEvent(affecting: "projectA")) 35 | XCTAssertEqual(strings("projectA"), view.highlightedProjects) 36 | checkEvents(expecting: .entered("projectA")) 37 | 38 | // First we'll fire an "exit" for projectA, and then an "enter" for projectB 39 | view.mouseExited(with: MockEvent(affecting: "projectA")) 40 | XCTAssertEqual(strings(), view.highlightedProjects) 41 | view.mouseEntered(with: MockEvent(affecting: "projectB")) 42 | XCTAssertEqual(strings("projectB"), view.highlightedProjects) 43 | 44 | checkEvents(expecting: .exited("projectA"), .entered("projectB")) 45 | } 46 | 47 | func testExplicitHighlighting() { 48 | view.highlightProject(named: "projectX") 49 | XCTAssertEqual(strings("projectX"), view.highlightedProjects) 50 | checkEvents(expecting: .entered("projectX")) 51 | 52 | view.highlightProject(named: "projectY") 53 | XCTAssertEqual(strings("projectX", "projectY"), view.highlightedProjects) 54 | checkEvents(expecting: .entered("projectY")) 55 | 56 | view.unhighlightProject(named: "projectX") 57 | XCTAssertEqual(strings("projectY"), view.highlightedProjects) 58 | checkEvents(expecting: .exited("projectX")) 59 | 60 | // unhighlight a second time; should be no-op 61 | view.unhighlightProject(named: "projectX") 62 | XCTAssertEqual(strings("projectY"), view.highlightedProjects) 63 | checkEvents() 64 | 65 | view.unhighlightProject(named: "projectY") 66 | XCTAssertEqual(strings(), view.highlightedProjects) 67 | checkEvents(expecting: .exited("projectY")) 68 | } 69 | 70 | func testExplicitlyHighlightedThenMouseIn() { 71 | // Highlight 72 | view.highlightProject(named: "projectA") 73 | XCTAssertEqual(strings("projectA"), view.highlightedProjects) 74 | checkEvents(expecting: .entered("projectA")) 75 | 76 | // Mouse-in should be no-op 77 | view.mouseEntered(with: MockEvent(affecting: "projectA")) 78 | XCTAssertEqual(strings("projectA"), view.highlightedProjects) 79 | checkEvents() 80 | } 81 | 82 | fileprivate func checkEvents(expecting expected: Event...) { 83 | XCTAssertEqual(expected, seenEvents) 84 | seenEvents.removeAll() 85 | } 86 | 87 | func strings(_ array: String...) -> Set { 88 | return Set(array) 89 | } 90 | 91 | fileprivate struct MockEvent: ProjectTrackedEvent { 92 | let affecting: String 93 | 94 | var projectName: String? { 95 | get { 96 | affecting 97 | } 98 | } 99 | } 100 | 101 | fileprivate enum Event: Equatable { 102 | case entered(String) 103 | case exited(String) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /whatdidTests/views/TextOptionsListTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | @testable import Whatdid 5 | 6 | class TextOptionsListTest: XCTestCase { 7 | 8 | private let callbacks = DummyCallbacks() 9 | private let view = TextOptionsList() 10 | 11 | override func setUpWithError() throws { 12 | view.willShow(callbacks: callbacks) 13 | } 14 | 15 | override func tearDownWithError() throws { 16 | // Put teardown code here. This method is called after the invocation of each test method in the class. 17 | } 18 | 19 | func testAutocompleteChangesSelection() throws { 20 | view.options = ["one", "two"] 21 | view.moveSelection(.up) 22 | XCTAssertEqual("two", view.selectedText) // sanity check 23 | let autocompleteSuggestion = view.onTextChanged(to: "on") 24 | XCTAssertEqual("one", autocompleteSuggestion) 25 | XCTAssertEqual("one", view.selectedText) 26 | } 27 | 28 | func testAutocompleteNegatesSelection() throws { 29 | view.options = ["one", "two"] 30 | view.moveSelection(.up) 31 | XCTAssertEqual("two", view.selectedText) // sanity check 32 | let autocompleteSuggestion = view.onTextChanged(to: "th") 33 | XCTAssertEqual("th", autocompleteSuggestion) 34 | XCTAssertNil(view.selectedText) 35 | } 36 | 37 | func testAutocompleteNegatesSelectionOneLetterAtATime() throws { 38 | view.options = ["one", "two"] 39 | view.moveSelection(.up) 40 | XCTAssertEqual("two", view.selectedText) // sanity check 41 | 42 | var autocompleteSuggestion = view.onTextChanged(to: "t") 43 | XCTAssertEqual("two", autocompleteSuggestion) 44 | XCTAssertEqual("two", view.selectedText) 45 | 46 | autocompleteSuggestion = view.onTextChanged(to: "th") 47 | XCTAssertEqual("th", autocompleteSuggestion) 48 | XCTAssertNil(view.selectedText) 49 | } 50 | 51 | func testAutocompleteSetsSelection() throws { 52 | view.options = ["one", "two"] 53 | XCTAssertNil(view.selectedText) // sanity check 54 | var autocompleteSuggestion = view.onTextChanged(to: "o") 55 | XCTAssertEqual("one", autocompleteSuggestion) 56 | XCTAssertEqual("one", view.selectedText) 57 | 58 | autocompleteSuggestion = view.onTextChanged(to: "on") 59 | XCTAssertEqual("one", autocompleteSuggestion) 60 | XCTAssertEqual("one", view.selectedText) 61 | } 62 | 63 | func testAutoCompleteKeepsSelectionWhenPossible() { 64 | view.options = ["one a", "one b", "one c"] 65 | view.moveSelection(.down) 66 | view.moveSelection(.down) 67 | XCTAssertEqual("one b", view.selectedText) // sanity check 68 | 69 | let autocompleteSuggestion = view.onTextChanged(to: "one") 70 | XCTAssertEqual("one b", autocompleteSuggestion) 71 | XCTAssertEqual("one b", view.selectedText) 72 | } 73 | 74 | func testClickAtOtherThanSelection() throws { 75 | guard let screen = NSScreen.main else { 76 | XCTFail("couldn't find main screen") 77 | fatalError() 78 | } 79 | view.options = ["one", "two"] 80 | 81 | let window = NSWindow( 82 | contentRect: NSRect(origin: NSPoint(x: screen.frame.midX, y: screen.frame.midY), size: self.view.fittingSize), 83 | styleMask: [.fullSizeContentView], 84 | backing: .buffered, 85 | defer: false) 86 | window.contentView = self.view 87 | 88 | view.moveSelection(.down) 89 | XCTAssertEqual("one", view.selectedText) // sanity check 90 | 91 | let clicked = view.handleClick(at: NSPoint(x: 10, y: view.bounds.maxY - 2)) // click near the bottom 92 | XCTAssertEqual("two", clicked) 93 | } 94 | 95 | private class DummyCallbacks: TextFieldWithPopupCallbacks { 96 | func contentSizeChanged() { 97 | // nothing 98 | } 99 | 100 | func scroll(to bounds: NSRect, within: NSView) { 101 | // nothing 102 | } 103 | 104 | func setText(to string: String) { 105 | // nothing 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /whatdidUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /whatdidUITests/component/DatePickerUtils.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | func findDatePickerBoxes(in picker: XCUIElement) -> [(CoordinateInfo, YearMonthDay)] { 6 | var results = [(CoordinateInfo, YearMonthDay)]() 7 | let firstCoordinate = CoordinateInfo(normalizedX: 0.1, normalizedY: 0.5, absoluteX: 0.0, absoluteY: 0.0) 8 | firstCoordinate.click(in: picker) 9 | var prevYmd = YearMonthDay.parse(from: picker.value as? String)! 10 | results.append((firstCoordinate, prevYmd)) 11 | 12 | for dx in stride(from: 0, to: picker.frame.width, by: 10) { 13 | let coordinate = firstCoordinate.withAbsoluteOffset(dx: dx, dy: 0) 14 | coordinate.click(in: picker) 15 | let currYmd = YearMonthDay.parse(from: picker.value as? String)! 16 | if currYmd == prevYmd { 17 | continue 18 | } else if currYmd == prevYmd.withAdditional(days: 1) { 19 | results.append((coordinate, currYmd)) 20 | prevYmd = currYmd 21 | if results.count == 3 { 22 | break // we only need 3! 23 | } 24 | } else { 25 | XCTFail("skipped from \(prevYmd) to \(currYmd)") 26 | } 27 | } 28 | 29 | return results 30 | } 31 | 32 | struct CoordinateInfo: Hashable { 33 | let normalizedX: CGFloat 34 | let normalizedY: CGFloat 35 | let absoluteX: CGFloat 36 | let absoluteY: CGFloat 37 | 38 | func click(in element: XCUIElement, thenDragTo destination: CoordinateInfo? = nil) { 39 | let firstCoordinate = toXCUICoordinate(in: element) 40 | if let destination = destination { 41 | let secondCoordinate = destination.toXCUICoordinate(in: element) 42 | firstCoordinate.click(forDuration: 0.5, thenDragTo: secondCoordinate) 43 | } else { 44 | firstCoordinate.click() 45 | } 46 | } 47 | 48 | func toXCUICoordinate(in element: XCUIElement) -> XCUICoordinate { 49 | return element 50 | .coordinate(withNormalizedOffset: CGVector(dx: normalizedX, dy: normalizedY)) 51 | .withOffset(CGVector(dx: absoluteX, dy: absoluteY)) 52 | } 53 | 54 | func withAbsoluteOffset(dx: CGFloat, dy: CGFloat) -> CoordinateInfo { 55 | return CoordinateInfo(normalizedX: normalizedX, normalizedY: normalizedY, absoluteX: absoluteX + dx, absoluteY: absoluteY + dy) 56 | } 57 | } 58 | 59 | struct YearMonthDay: Equatable { 60 | var year: Int 61 | var month: Int 62 | var day: Int 63 | 64 | var asDashedString: String { 65 | return "\(year)-\(month)-\(day)" 66 | } 67 | 68 | var asDate: Date { 69 | return DateComponents( 70 | calendar: Calendar.current, 71 | timeZone: .utc, 72 | year: year, 73 | month: month, 74 | day: day, 75 | hour: 7, // start of day is 9am athens time, or 7am UTC 76 | minute: 0, 77 | second: 0 78 | ).date! 79 | } 80 | 81 | func withAdditional(years: Int = 0, months: Int = 0, days: Int = 0) -> YearMonthDay { 82 | return YearMonthDay(year: year + years, month: month + months, day: day + days) 83 | } 84 | 85 | static func parse(from string: String?) -> YearMonthDay? { 86 | guard let segments = string?.split(separator: "-", maxSplits: 2), 87 | segments.count == 3, 88 | let yy = Int(segments[0]), 89 | let mm = Int(segments[1]), 90 | let dd = Int(segments[2]) 91 | else { 92 | return nil 93 | } 94 | return YearMonthDay(year: yy, month: mm, day: dd) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /whatdidUITests/extensions/ExtraStringHelpers.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import Foundation 4 | 5 | extension String { 6 | 7 | var rot13: String { 8 | get { 9 | let trChars = self.map {c -> Character in 10 | if let tr = rot13Char(for: .upper, map: c) { 11 | return tr 12 | } else if let tr = rot13Char(for: .lower, map: c) { 13 | return tr 14 | } else { 15 | return c 16 | } 17 | } 18 | return String(trChars) 19 | } 20 | } 21 | } 22 | 23 | private enum Capitalization: Character { 24 | case upper = "A" 25 | case lower = "a" 26 | } 27 | 28 | private func rot13Char(for capitalization: Capitalization, map target: Character) -> Character? { 29 | guard let fromUtf8 = capitalization.rawValue.asciiValue, let targetUtf8 = target.asciiValue else { 30 | return nil 31 | } 32 | let toUtf8 = fromUtf8 + 26 33 | guard targetUtf8 >= fromUtf8 && targetUtf8 < toUtf8 else { 34 | return nil 35 | } 36 | let char0Indexed = targetUtf8 - fromUtf8 37 | let mapped = (char0Indexed + 13) % 26 38 | return Character(UnicodeScalar(mapped + fromUtf8)) 39 | } 40 | -------------------------------------------------------------------------------- /whatdidUITests/extensions/ExtraStringHelpersTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidTests? 2 | 3 | import XCTest 4 | 5 | class String_HelpersTest: XCTestCase { 6 | 7 | func testRot13() throws { 8 | XCTAssertEqual( 9 | "the quick brown fox jumped over the lazy dog".rot13, 10 | "gur dhvpx oebja sbk whzcrq bire gur ynml qbt") 11 | XCTAssertEqual( 12 | "THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG".rot13, 13 | "GUR DHVPX OEBJA SBK WHZCRQ BIRE GUR YNML QBT") 14 | XCTAssertEqual( 15 | "\t1234☃", 16 | "\t1234☃") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /whatdidUITests/extensions/XCTestCase+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | extension XCTestCase { 6 | class func group(_ name: String, _ block: () -> R) -> R { 7 | return XCTContext.runActivity(named: name, block: {_ in return block()}) 8 | } 9 | 10 | class func pauseToLetStabilize() { 11 | sleepMillis(150) 12 | } 13 | 14 | func pauseToLetStabilize() { 15 | XCTestCase.sleepMillis(150) 16 | } 17 | 18 | class func sleepMillis(_ ms: Int) { 19 | usleep(useconds_t(ms * 1000)) 20 | } 21 | 22 | func sleepMillis(_ ms: Int) { 23 | XCTestCase.sleepMillis(ms) 24 | } 25 | 26 | func group(_ name: String, _ block: () -> R) -> R { 27 | return XCTestCase.group(name, block) 28 | } 29 | 30 | class func log(_ message: String) { 31 | group(message) {} 32 | } 33 | 34 | func log(_ message: String) { 35 | XCTestCase.log(message) 36 | } 37 | 38 | func wait(until block: () -> T, equals expected: T) { 39 | wait(for: "element to equal expected", until: {block() == expected}) 40 | } 41 | 42 | func wait(for description: String, timeout: TimeInterval = 30, until condition: () -> Bool) { 43 | XCTestCase.wait(for: description, until: condition) 44 | } 45 | 46 | func XCTAssertClose(_ first: CGFloat, _ second: CGFloat, within allowedSpread: CGFloat) { 47 | let delta = abs(first - second) 48 | XCTAssertLessThanOrEqual(delta, allowedSpread, "\(first) was not within \(allowedSpread) of \(second)") 49 | } 50 | 51 | class func wait(for description: String, timeout: TimeInterval = 30, until condition: () -> Bool) { 52 | let delay: TimeInterval = 1 53 | group("Waiting for \(description)") { 54 | let tryUntil = Date().addingTimeInterval(timeout) 55 | for i in 1... { 56 | let success = group("Attempt #\(i)") {() -> Bool in 57 | if condition() { 58 | log("Success") 59 | return true 60 | } 61 | if Date() > tryUntil { 62 | XCTFail("Timed out after \(timeout)s") 63 | } 64 | sleepMillis(Int(delay * 1000)) 65 | return false 66 | } 67 | if success { 68 | return 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /whatdidUITests/extensions/XCUIElementQuery+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | extension XCUIElementQuery { 6 | /// Returns whether at least one element matches the given predicate 7 | /// 8 | /// This function can include predicates that aren't XCUIElement-codable (and which therefore can't be matched via `matching(predicate)`). 9 | /// Since it stops as soon as it hits the limit, it can be faster than reifying the whole query to a `[XCUIElement]` and then filtering that array. 10 | func hasAtLeastOneElement(where predicate: (XCUIElement) -> Bool) -> Bool { 11 | XCTestCase.group("XCUIElementQuery.hasAtLeastOneElement") { 12 | for i in 0... { 13 | let possibleAnswer = XCTestCase.group("checking element #\(i)") {() -> Bool? in 14 | let e = element(boundBy: i) 15 | guard e.exists else { 16 | return false 17 | } 18 | if predicate(e) { 19 | return true 20 | } 21 | return nil 22 | } 23 | if let answer = possibleAnswer { 24 | return answer 25 | } 26 | } 27 | return false 28 | } 29 | } 30 | 31 | var firstMatchMaybe: XCUIElement? { 32 | allElementsBoundByIndex.first 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /whatdidUITests/extensions/XCUIKeyboardKey+Helpers.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | extension XCUIKeyboardKey { 6 | var prettyString: String { 7 | switch self { 8 | case .delete: return "delete" 9 | case .`return`: return "return" 10 | case .enter: return "enter" 11 | case .tab: return "tab" 12 | case .space: return "space" 13 | case .escape: return "escape" 14 | case .upArrow: return "upArrow" 15 | case .downArrow: return "downArrow" 16 | case .leftArrow: return "leftArrow" 17 | case .rightArrow: return "rightArrow" 18 | case .F1: return "F1" 19 | case .F2: return "F2" 20 | case .F3: return "F3" 21 | case .F4: return "F4" 22 | case .F5: return "F5" 23 | case .F6: return "F6" 24 | case .F7: return "F7" 25 | case .F8: return "F8" 26 | case .F9: return "F9" 27 | case .F10: return "F10" 28 | case .F11: return "F11" 29 | case .F12: return "F12" 30 | case .F13: return "F13" 31 | case .F14: return "F14" 32 | case .F15: return "F15" 33 | case .F16: return "F16" 34 | case .F17: return "F17" 35 | case .F18: return "F18" 36 | case .F19: return "F19" 37 | case .forwardDelete: return "forwardDelete" 38 | case .home: return "home" 39 | case .end: return "end" 40 | case .pageUp: return "pageUp" 41 | case .pageDown: return "pageDown" 42 | case .clear: return "clear" 43 | case .help: return "help" 44 | case .capsLock: return "capsLock" 45 | case .shift: return "shift" 46 | case .control: return "control" 47 | case .option: return "option" 48 | case .command: return "command" 49 | case .rightShift: return "rightShift" 50 | case .rightControl: return "rightControl" 51 | case .rightOption: return "rightOption" 52 | case .rightCommand: return "rightCommand" 53 | case .secondaryFn: return "secondaryFn" 54 | 55 | default: 56 | return rawValue.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? rawValue 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /whatdidUITests/model/FlatEntry+SerdeTest.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | class FlatEntry_SerdeTest: XCTestCase { 6 | 7 | /// There and back 8 | func testSerde() { 9 | // If I use just "Date()", rounding errors at microsecond precision can cause this to 10 | // fail every once in a while; we lose a few microseconds here and there during the serde process. 11 | let toDate = Date(timeIntervalSince1970: 1596931932) 12 | let fromDate = toDate.addingTimeInterval(-12345) 13 | let orig = [FlatEntry(from: fromDate, to: toDate, project: "p1", task: "t1", notes: "my notes")] 14 | 15 | let serialized = FlatEntry.serialize(orig) 16 | XCTAssertNotNil(serialized) 17 | 18 | let fromJson = FlatEntry.deserialize(from: serialized) 19 | XCTAssertEqual(fromJson, orig) 20 | } 21 | 22 | /// Just so we're sure it's not pretty-printed; we want a nice compact format 23 | func testSerdeHasNoWhitespace() { 24 | let toDate = Date() 25 | let fromDate = toDate.addingTimeInterval(-12345) 26 | let orig = [FlatEntry(from: fromDate, to: toDate, project: "p1", task: "t1", notes: "notes")] 27 | 28 | let json = FlatEntry.serialize(orig) 29 | XCTAssertNotNil(json) 30 | 31 | let whiteSpace = json.firstIndex(where: {$0.isWhitespace}) 32 | XCTAssertNil(whiteSpace) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /whatdidUITests/util/AutocompleteFieldHelper.swift: -------------------------------------------------------------------------------- 1 | // whatdidUITests? 2 | 3 | import XCTest 4 | 5 | struct AutocompleteFieldHelper { 6 | let element: XCUIElement 7 | private var origButtonHeight: CGFloat 8 | 9 | init(element: XCUIElement) { 10 | self.element = element 11 | origButtonHeight = -1 // need to init this so we can use the "button" computed property 12 | origButtonHeight = button.frame.height 13 | } 14 | 15 | /// The editable text field 16 | var textField: XCUIElement { 17 | element 18 | } 19 | 20 | /// The popup button that toggles the options pane 21 | var button: XCUIElement { 22 | return element.children(matching: .button).element 23 | } 24 | 25 | /// The options pane, or fail if it is not open 26 | var optionsScroll: XCUIElement { 27 | return XCTestCase.group("FieldHelper: fetch optionsScroll") { 28 | let allScrolls = element.children(matching: .scrollView).allElementsBoundByIndex 29 | XCTAssertEqual(1, allScrolls.count, "Expected exactly one options pane") 30 | return allScrolls[0] 31 | } 32 | } 33 | 34 | var optionsScrollIsOpen: Bool { 35 | return XCTestCase.group("FieldHelper: fetch optionsScroll") { 36 | switch element.children(matching: .scrollView).allElementsBoundByIndex.count { 37 | case 0: 38 | return false 39 | case 1: 40 | return true 41 | default: 42 | XCTFail("expected 0 or 1 optionsScrolls") 43 | return false 44 | } 45 | } 46 | } 47 | 48 | func assertOptionsPaneHidden() { 49 | XCTAssertEqual(0, element.children(matching: .scrollView).count) 50 | } 51 | 52 | /// The options pane is open but empty 53 | func assertOptionsOpenButEmpty() { 54 | XCTestCase.group("FieldHelper: assertOptionsOpenButEmpty") { 55 | XCTAssertEqual(0, optionsScroll.children(matching: .textField).count) 56 | } 57 | } 58 | 59 | func checkOptionsScrollFrameHeight() { 60 | let textFieldFrame = textField.frame 61 | let optionsScrollFrame = optionsScroll.frame 62 | let buttonFrame = button.frame 63 | XCTAssertEqual(textFieldFrame.minY.rounded(.down), buttonFrame.minY.rounded(.down)) 64 | XCTAssertEqual(origButtonHeight.rounded(.down), buttonFrame.height.rounded(.down)) 65 | XCTAssertEqual(textFieldFrame.maxY.rounded(.down) + 2, optionsScrollFrame.minY.rounded(.down)) 66 | } 67 | 68 | var hasFocus: Bool { 69 | return textField.hasFocus 70 | } 71 | 72 | var optionTextFieldsQuery: XCUIElementQuery { 73 | return optionsScroll.descendants(matching: .textField) 74 | } 75 | 76 | /// The available options; fails if the options pane is not open 77 | /// 78 | /// This computed property is slow if there are many options. 79 | var optionTextFieldsByIndex: [XCUIElement] { 80 | optionTextFieldsQuery.allElementsBoundByIndex 81 | } 82 | 83 | /// A faster variant of `optionTextFields[index]` 84 | func optionTextField(atIndex index: Int) -> XCUIElement { 85 | return optionsScroll.descendants(matching: .textField).element(boundBy: index) 86 | } 87 | 88 | /// The available options' string values; fails if the options pane is not open. 89 | /// 90 | /// This computed property is slow if there are many options. 91 | var optionTextStrings: [String] { 92 | return XCTestCase.group("Find option text field strings") { 93 | optionTextFieldsByIndex.map { $0.stringValue } 94 | } 95 | } 96 | 97 | private var selectedOptions: [String] { 98 | optionTextFieldsQuery.matching(NSPredicate(format: "isSelected = true")) 99 | .allElementsBoundByIndex 100 | .map{$0.stringValue} 101 | } 102 | 103 | /// The options pane is open, but no field is selected 104 | func assertNoOptionSelected() { 105 | return XCTestCase.group("Look for no selected options") { 106 | XCTAssertEqual([], selectedOptions) 107 | } 108 | } 109 | 110 | /// The selected option's string value; fails if the options pane is not open 111 | var selectedOptionText: String { 112 | return XCTestCase.group("Find the selected option") { 113 | let selecteds = selectedOptions 114 | XCTAssertEqual(1, selecteds.count, "expected exactly one selected option") 115 | return selecteds[0] 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /whatdidUITests/whatdidUITests.swift: -------------------------------------------------------------------------------- 1 | // whatdid? 2 | 3 | import XCTest 4 | @testable import whatdid 5 | 6 | class ComponentUITests: XCTestCase { 7 | 8 | private var app: XCUIApplication! 9 | 10 | override func setUp() { 11 | continueAfterFailure = false 12 | app = XCUIApplication() 13 | app.launch() 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | XCUIApplication().terminate() 18 | } 19 | 20 | var window: XCUIElement { 21 | return app.windows["uitestwindow"] 22 | } 23 | 24 | private func component(_ name: String) { 25 | window.popUpButtons["componentselector"].click() 26 | window.menuItems[name].click() 27 | } 28 | 29 | func testButtonWithClosure() { 30 | 31 | 32 | component("ButtonWithClosure") 33 | let button = window.buttons["Button"] 34 | let createdLabels = window.staticTexts.matching(NSPredicate(format: "label CONTAINS 'pressed on self'")) 35 | XCTAssertEqual(createdLabels.count, 0) 36 | 37 | button.click() 38 | XCTAssertEqual(createdLabels.count, 1) 39 | XCTAssertEqual( 40 | ["count=1, pressed on self=true"], 41 | createdLabels.allElementsBoundByIndex.map({$0.label})) 42 | 43 | button.click() 44 | XCTAssertEqual(createdLabels.count, 2) 45 | XCTAssertEqual( 46 | ["count=1, pressed on self=true", "count=2, pressed on self=true"], 47 | createdLabels.allElementsBoundByIndex.map({$0.label})) 48 | } 49 | } 50 | --------------------------------------------------------------------------------