├── .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 |
--------------------------------------------------------------------------------