├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Typewriter.xcodeproj └── project.pbxproj └── Typewriter ├── AppDelegate.swift ├── Assets.xcassets └── AppIcon.appiconset │ └── Contents.json ├── Base.lproj └── Main.storyboard ├── BottomOverscrollFlexibleTypewriterMode.swift ├── FinalFantasyTypewriterMode.swift ├── FixedTypewriterMode.swift ├── FullOverscrollFlexibleTypewriterMode.swift ├── Info.plist ├── TypewriterMode.swift ├── TypewriterTextStorage.swift ├── TypewriterTextView.swift └── ViewController.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | .build/ 41 | 42 | # CocoaPods 43 | # 44 | # We recommend against adding the Pods directory to your .gitignore. However 45 | # you should judge for yourself, the pros and cons are mentioned at: 46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 47 | # 48 | # Pods/ 49 | 50 | # Carthage 51 | # 52 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 53 | # Carthage/Checkouts 54 | 55 | Carthage/Build 56 | 57 | # fastlane 58 | # 59 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 60 | # screenshots whenever they are needed. 61 | # For more information about the recommended setup visit: 62 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 63 | 64 | fastlane/report.xml 65 | fastlane/Preview.html 66 | fastlane/screenshots 67 | fastlane/test_output 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Typewriter Modes project 2 | 3 | You're very welcome to suggest, change, adapt, and replace anything you like in a pull request! If you want to propose a dramatic change, please open an issue first for discussion before making a pull request, though. Because discussions in PRs often get messy easily. 4 | 5 | That being said, the usual guidelines apply: 6 | 7 | * fork this repository, 8 | * commit changes to a new branch in your fork, 9 | * create a pull request from your branch to this repository's `master`. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christian Tietze 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 | # Typewriter Text View 2 | 3 | For many years, the Mac platform has pioneered minimal writing apps. Apart from the distraction-free full-screen modes, popular writing apps sport a "typewriter mode". 4 | 5 | Typewriter scrolling means: the text insertion point always maintains the same vertical distance to the window edges when you type. If you hit "enter", the document is supposed to move up one line so the insertion point stays fixed. This is a nice effect when you write because your eyes don't need to move that much. They can focus on one line as you type and the document goes out of the way. It's called "typewriter scrolling" because a typewriter punches letters at the same spot, moving the paper around. 6 | 7 | ## Typewriter Modes 8 | 9 | The sample app supports a few different typewriter modes. 10 | 11 | ### Fixed 12 | 13 | Fixed typewriter mode is the most simple: 14 | 15 | * You, the developer, decide where the line of the insertion point is placed. When the mode is enabled, the text view is locked to that predefined location. 16 | * If the user moves around with the arrow keys, clicks or scrolls around, the app behaves regularly, like TextEdit. The document does _not_ scroll to center on the new insertion point. Only when the user types does the document center on the line again. 17 | 18 | The insertion point is relative to the window edges. It should be in the most comfortable position. Assuming your app is going to be used in full-screen mode or at least taking up most of the vertical space of the screen, you can think of window height and screen height as almost always being equal. Looking at what `NSWindow.center()` does, placing a window not at the vertical center but slightly above it, I suggest you do the same for a more pleasing experience. Compute the distance whenever the parent `NSScrollView` changes and keep it at somewhere between 35%--45% of the scroll view's height from the top. 19 | 20 | When you figure this out once, you're done and it'll just work: user enables typewriter mode, insertion point is moved to the expected position and stays put. 21 | 22 | This mode is easy to understand because the fixed on-screen position always behaves the same. It's easy to make predictions even for newbies. 23 | 24 | 25 | ### True 26 | 27 | Due to a lack of better terms, "True Typewriter Mode" is what I call a fixed mode with a few tweaks. The insertion point always stays where it is, that means: 28 | 29 | * Pressing the "up" arrow key scrolls the document down 1 line. In absolute screen coordinates, the insertion point did not move. The document beneath it moved. 30 | * Similarly, scrolling with the trackpad or mouse wheel moves the document around and moves the insertion point with it. 31 | 32 | I call this "true" because that's what a real typewriter does. You can move the paper around to focus on another line, but the insertion point location changes with it. 33 | 34 | This mode always keeps the insertion point in place on-screen. There's no difference between scrolling and using the arrow keys to move the insertion point around. You cannot scroll per-pixel but only per-line. The scrolling movement is very jagged this way, like aliens from Space Invaders coming closer. This mode may be useful to give a special kind of uninterrupted focus to your app's users. But it's mostly suited for composing, not editing text, because scrolling around in a document isn't smooth and creates a lot of visual noise. 35 | 36 | **At the moment, this repository does not have an example for this.** 37 | 38 | 39 | ### Flexible 40 | 41 | This mode doesn't have a fixed on-screen position for the insertion point. It's called "flexible" because any position can become the locked 42 | 43 | * Wherever the user places the insertion point (with mouse or arrow keys), that position is locked to becomes the new insertion point location. 44 | * Scrolling does not affect the insertion point or the locked distance relative to the window edges, unlike "True Typewriter Mode". When you scroll away and then press a key to type, you're taken back to the place where you've been before, like in a regular TextEdit session. 45 | 46 | This gives the user of your app the most freedom to decide how much space to the top (and bottom) of the screen should be maintained by the app. Vertically centering the insertion point is useful and pleasant to just be typing away and users can do that at will. Keeping the insertion point locked further to the top means that the bottom part stays put on-screen. That's useful for some scenarios; if you put a list of topics below your insertion point, this to-do list will stay on screen while you write. As a downside, this flexible behavior is harder to predict at first and chances are your users will like a fixed approach better because its results are more predictable. Scrolling the document and moving the insertion point where you like it best is fiddly. 47 | 48 | ## Base Techniques 49 | 50 | I explain the parts in blog posts instead of making the readme even longer: 51 | 52 | * [Overscrolling](http://cleancocoa.com/posts/2017/07/typewriter-mode-overscrolling/) 53 | 54 | 55 | ## Contributing 56 | 57 | There's still work to do to make all typewriter modes a pleasant experience. I'd love to see you involved! 58 | 59 | For technical details, have a look at the [Contributing Guide](/CONTRIBUTING.md). 60 | 61 | ## License 62 | 63 | Copyright (c) 2017 Christian Tietze. Distributed under the MIT License. 64 | -------------------------------------------------------------------------------- /Typewriter.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 5086C1BF1F15FAEA007A2D75 /* TypewriterMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086C1BE1F15FAEA007A2D75 /* TypewriterMode.swift */; }; 11 | 5086C1C11F15FB05007A2D75 /* BottomOverscrollFlexibleTypewriterMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086C1C01F15FB05007A2D75 /* BottomOverscrollFlexibleTypewriterMode.swift */; }; 12 | 5086C1C31F15FBC0007A2D75 /* FullOverscrollFlexibleTypewriterMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086C1C21F15FBC0007A2D75 /* FullOverscrollFlexibleTypewriterMode.swift */; }; 13 | 5086C1C51F1611DB007A2D75 /* FixedTypewriterMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086C1C41F1611DB007A2D75 /* FixedTypewriterMode.swift */; }; 14 | 508B7D7B1F16289F0001BFA7 /* FinalFantasyTypewriterMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508B7D7A1F16289F0001BFA7 /* FinalFantasyTypewriterMode.swift */; }; 15 | 50CBA4801F0A35760092E8ED /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CBA47F1F0A35760092E8ED /* AppDelegate.swift */; }; 16 | 50CBA4821F0A35760092E8ED /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CBA4811F0A35760092E8ED /* ViewController.swift */; }; 17 | 50CBA4841F0A35760092E8ED /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50CBA4831F0A35760092E8ED /* Assets.xcassets */; }; 18 | 50CBA4871F0A35760092E8ED /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50CBA4851F0A35760092E8ED /* Main.storyboard */; }; 19 | 50EE35321F0CE5C500918B01 /* TypewriterTextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE35311F0CE5C500918B01 /* TypewriterTextStorage.swift */; }; 20 | 50EE35341F0CE5F600918B01 /* TypewriterTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE35331F0CE5F600918B01 /* TypewriterTextView.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | 507C47FC1F189D13009D43E3 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 25 | 507C47FD1F189D13009D43E3 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 26 | 507C48001F189F38009D43E3 /* CONTRIBUTING.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; 27 | 5086C1BE1F15FAEA007A2D75 /* TypewriterMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypewriterMode.swift; sourceTree = ""; }; 28 | 5086C1C01F15FB05007A2D75 /* BottomOverscrollFlexibleTypewriterMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomOverscrollFlexibleTypewriterMode.swift; sourceTree = ""; }; 29 | 5086C1C21F15FBC0007A2D75 /* FullOverscrollFlexibleTypewriterMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullOverscrollFlexibleTypewriterMode.swift; sourceTree = ""; }; 30 | 5086C1C41F1611DB007A2D75 /* FixedTypewriterMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FixedTypewriterMode.swift; sourceTree = ""; }; 31 | 508B7D7A1F16289F0001BFA7 /* FinalFantasyTypewriterMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FinalFantasyTypewriterMode.swift; sourceTree = ""; }; 32 | 50CBA47C1F0A35760092E8ED /* Typewriter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Typewriter.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | 50CBA47F1F0A35760092E8ED /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 34 | 50CBA4811F0A35760092E8ED /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 35 | 50CBA4831F0A35760092E8ED /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 36 | 50CBA4861F0A35760092E8ED /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 37 | 50CBA4881F0A35760092E8ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 38 | 50EE35311F0CE5C500918B01 /* TypewriterTextStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypewriterTextStorage.swift; sourceTree = ""; }; 39 | 50EE35331F0CE5F600918B01 /* TypewriterTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypewriterTextView.swift; sourceTree = ""; }; 40 | /* End PBXFileReference section */ 41 | 42 | /* Begin PBXFrameworksBuildPhase section */ 43 | 50CBA4791F0A35760092E8ED /* Frameworks */ = { 44 | isa = PBXFrameworksBuildPhase; 45 | buildActionMask = 2147483647; 46 | files = ( 47 | ); 48 | runOnlyForDeploymentPostprocessing = 0; 49 | }; 50 | /* End PBXFrameworksBuildPhase section */ 51 | 52 | /* Begin PBXGroup section */ 53 | 50CBA4731F0A35760092E8ED = { 54 | isa = PBXGroup; 55 | children = ( 56 | 507C47FD1F189D13009D43E3 /* README.md */, 57 | 507C48001F189F38009D43E3 /* CONTRIBUTING.md */, 58 | 507C47FC1F189D13009D43E3 /* LICENSE */, 59 | 50CBA47E1F0A35760092E8ED /* Typewriter */, 60 | 50CBA47D1F0A35760092E8ED /* Products */, 61 | ); 62 | sourceTree = ""; 63 | }; 64 | 50CBA47D1F0A35760092E8ED /* Products */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | 50CBA47C1F0A35760092E8ED /* Typewriter.app */, 68 | ); 69 | name = Products; 70 | sourceTree = ""; 71 | }; 72 | 50CBA47E1F0A35760092E8ED /* Typewriter */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | 50CBA47F1F0A35760092E8ED /* AppDelegate.swift */, 76 | 50CBA4811F0A35760092E8ED /* ViewController.swift */, 77 | 50EE35311F0CE5C500918B01 /* TypewriterTextStorage.swift */, 78 | 50EE35331F0CE5F600918B01 /* TypewriterTextView.swift */, 79 | 5086C1BE1F15FAEA007A2D75 /* TypewriterMode.swift */, 80 | 5086C1C41F1611DB007A2D75 /* FixedTypewriterMode.swift */, 81 | 508B7D7A1F16289F0001BFA7 /* FinalFantasyTypewriterMode.swift */, 82 | 5086C1C21F15FBC0007A2D75 /* FullOverscrollFlexibleTypewriterMode.swift */, 83 | 5086C1C01F15FB05007A2D75 /* BottomOverscrollFlexibleTypewriterMode.swift */, 84 | 50CBA4831F0A35760092E8ED /* Assets.xcassets */, 85 | 50CBA4851F0A35760092E8ED /* Main.storyboard */, 86 | 50CBA4881F0A35760092E8ED /* Info.plist */, 87 | ); 88 | path = Typewriter; 89 | sourceTree = ""; 90 | }; 91 | /* End PBXGroup section */ 92 | 93 | /* Begin PBXNativeTarget section */ 94 | 50CBA47B1F0A35760092E8ED /* Typewriter */ = { 95 | isa = PBXNativeTarget; 96 | buildConfigurationList = 50CBA48B1F0A35770092E8ED /* Build configuration list for PBXNativeTarget "Typewriter" */; 97 | buildPhases = ( 98 | 50CBA4781F0A35760092E8ED /* Sources */, 99 | 50CBA4791F0A35760092E8ED /* Frameworks */, 100 | 50CBA47A1F0A35760092E8ED /* Resources */, 101 | ); 102 | buildRules = ( 103 | ); 104 | dependencies = ( 105 | ); 106 | name = Typewriter; 107 | productName = Typewriter; 108 | productReference = 50CBA47C1F0A35760092E8ED /* Typewriter.app */; 109 | productType = "com.apple.product-type.application"; 110 | }; 111 | /* End PBXNativeTarget section */ 112 | 113 | /* Begin PBXProject section */ 114 | 50CBA4741F0A35760092E8ED /* Project object */ = { 115 | isa = PBXProject; 116 | attributes = { 117 | LastSwiftUpdateCheck = 0830; 118 | LastUpgradeCheck = 0830; 119 | ORGANIZATIONNAME = "Christian Tietze"; 120 | TargetAttributes = { 121 | 50CBA47B1F0A35760092E8ED = { 122 | CreatedOnToolsVersion = 8.3.2; 123 | DevelopmentTeam = FRMDA3XRGC; 124 | ProvisioningStyle = Automatic; 125 | }; 126 | }; 127 | }; 128 | buildConfigurationList = 50CBA4771F0A35760092E8ED /* Build configuration list for PBXProject "Typewriter" */; 129 | compatibilityVersion = "Xcode 3.2"; 130 | developmentRegion = English; 131 | hasScannedForEncodings = 0; 132 | knownRegions = ( 133 | en, 134 | Base, 135 | ); 136 | mainGroup = 50CBA4731F0A35760092E8ED; 137 | productRefGroup = 50CBA47D1F0A35760092E8ED /* Products */; 138 | projectDirPath = ""; 139 | projectRoot = ""; 140 | targets = ( 141 | 50CBA47B1F0A35760092E8ED /* Typewriter */, 142 | ); 143 | }; 144 | /* End PBXProject section */ 145 | 146 | /* Begin PBXResourcesBuildPhase section */ 147 | 50CBA47A1F0A35760092E8ED /* Resources */ = { 148 | isa = PBXResourcesBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | 50CBA4841F0A35760092E8ED /* Assets.xcassets in Resources */, 152 | 50CBA4871F0A35760092E8ED /* Main.storyboard in Resources */, 153 | ); 154 | runOnlyForDeploymentPostprocessing = 0; 155 | }; 156 | /* End PBXResourcesBuildPhase section */ 157 | 158 | /* Begin PBXSourcesBuildPhase section */ 159 | 50CBA4781F0A35760092E8ED /* Sources */ = { 160 | isa = PBXSourcesBuildPhase; 161 | buildActionMask = 2147483647; 162 | files = ( 163 | 5086C1C51F1611DB007A2D75 /* FixedTypewriterMode.swift in Sources */, 164 | 5086C1C11F15FB05007A2D75 /* BottomOverscrollFlexibleTypewriterMode.swift in Sources */, 165 | 508B7D7B1F16289F0001BFA7 /* FinalFantasyTypewriterMode.swift in Sources */, 166 | 50CBA4821F0A35760092E8ED /* ViewController.swift in Sources */, 167 | 5086C1C31F15FBC0007A2D75 /* FullOverscrollFlexibleTypewriterMode.swift in Sources */, 168 | 50CBA4801F0A35760092E8ED /* AppDelegate.swift in Sources */, 169 | 5086C1BF1F15FAEA007A2D75 /* TypewriterMode.swift in Sources */, 170 | 50EE35341F0CE5F600918B01 /* TypewriterTextView.swift in Sources */, 171 | 50EE35321F0CE5C500918B01 /* TypewriterTextStorage.swift in Sources */, 172 | ); 173 | runOnlyForDeploymentPostprocessing = 0; 174 | }; 175 | /* End PBXSourcesBuildPhase section */ 176 | 177 | /* Begin PBXVariantGroup section */ 178 | 50CBA4851F0A35760092E8ED /* Main.storyboard */ = { 179 | isa = PBXVariantGroup; 180 | children = ( 181 | 50CBA4861F0A35760092E8ED /* Base */, 182 | ); 183 | name = Main.storyboard; 184 | sourceTree = ""; 185 | }; 186 | /* End PBXVariantGroup section */ 187 | 188 | /* Begin XCBuildConfiguration section */ 189 | 50CBA4891F0A35770092E8ED /* Debug */ = { 190 | isa = XCBuildConfiguration; 191 | buildSettings = { 192 | ALWAYS_SEARCH_USER_PATHS = NO; 193 | CLANG_ANALYZER_NONNULL = YES; 194 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 195 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 196 | CLANG_CXX_LIBRARY = "libc++"; 197 | CLANG_ENABLE_MODULES = YES; 198 | CLANG_ENABLE_OBJC_ARC = YES; 199 | CLANG_WARN_BOOL_CONVERSION = YES; 200 | CLANG_WARN_CONSTANT_CONVERSION = YES; 201 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 202 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 203 | CLANG_WARN_EMPTY_BODY = YES; 204 | CLANG_WARN_ENUM_CONVERSION = YES; 205 | CLANG_WARN_INFINITE_RECURSION = YES; 206 | CLANG_WARN_INT_CONVERSION = YES; 207 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 208 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 209 | CLANG_WARN_UNREACHABLE_CODE = YES; 210 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 211 | CODE_SIGN_IDENTITY = "-"; 212 | COPY_PHASE_STRIP = NO; 213 | DEBUG_INFORMATION_FORMAT = dwarf; 214 | ENABLE_STRICT_OBJC_MSGSEND = YES; 215 | ENABLE_TESTABILITY = YES; 216 | GCC_C_LANGUAGE_STANDARD = gnu99; 217 | GCC_DYNAMIC_NO_PIC = NO; 218 | GCC_NO_COMMON_BLOCKS = YES; 219 | GCC_OPTIMIZATION_LEVEL = 0; 220 | GCC_PREPROCESSOR_DEFINITIONS = ( 221 | "DEBUG=1", 222 | "$(inherited)", 223 | ); 224 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 225 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 226 | GCC_WARN_UNDECLARED_SELECTOR = YES; 227 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 228 | GCC_WARN_UNUSED_FUNCTION = YES; 229 | GCC_WARN_UNUSED_VARIABLE = YES; 230 | MACOSX_DEPLOYMENT_TARGET = 10.12; 231 | MTL_ENABLE_DEBUG_INFO = YES; 232 | ONLY_ACTIVE_ARCH = YES; 233 | SDKROOT = macosx; 234 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 235 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 236 | }; 237 | name = Debug; 238 | }; 239 | 50CBA48A1F0A35770092E8ED /* Release */ = { 240 | isa = XCBuildConfiguration; 241 | buildSettings = { 242 | ALWAYS_SEARCH_USER_PATHS = NO; 243 | CLANG_ANALYZER_NONNULL = YES; 244 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 245 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 246 | CLANG_CXX_LIBRARY = "libc++"; 247 | CLANG_ENABLE_MODULES = YES; 248 | CLANG_ENABLE_OBJC_ARC = YES; 249 | CLANG_WARN_BOOL_CONVERSION = YES; 250 | CLANG_WARN_CONSTANT_CONVERSION = YES; 251 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 252 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 253 | CLANG_WARN_EMPTY_BODY = YES; 254 | CLANG_WARN_ENUM_CONVERSION = YES; 255 | CLANG_WARN_INFINITE_RECURSION = YES; 256 | CLANG_WARN_INT_CONVERSION = YES; 257 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 258 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 259 | CLANG_WARN_UNREACHABLE_CODE = YES; 260 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 261 | CODE_SIGN_IDENTITY = "-"; 262 | COPY_PHASE_STRIP = NO; 263 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 264 | ENABLE_NS_ASSERTIONS = NO; 265 | ENABLE_STRICT_OBJC_MSGSEND = YES; 266 | GCC_C_LANGUAGE_STANDARD = gnu99; 267 | GCC_NO_COMMON_BLOCKS = YES; 268 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 269 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 270 | GCC_WARN_UNDECLARED_SELECTOR = YES; 271 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 272 | GCC_WARN_UNUSED_FUNCTION = YES; 273 | GCC_WARN_UNUSED_VARIABLE = YES; 274 | MACOSX_DEPLOYMENT_TARGET = 10.12; 275 | MTL_ENABLE_DEBUG_INFO = NO; 276 | SDKROOT = macosx; 277 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 278 | }; 279 | name = Release; 280 | }; 281 | 50CBA48C1F0A35770092E8ED /* Debug */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 285 | COMBINE_HIDPI_IMAGES = YES; 286 | DEVELOPMENT_TEAM = FRMDA3XRGC; 287 | INFOPLIST_FILE = Typewriter/Info.plist; 288 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; 289 | PRODUCT_BUNDLE_IDENTIFIER = de.christiantietze.Typewriter; 290 | PRODUCT_NAME = "$(TARGET_NAME)"; 291 | SWIFT_VERSION = 3.0; 292 | }; 293 | name = Debug; 294 | }; 295 | 50CBA48D1F0A35770092E8ED /* Release */ = { 296 | isa = XCBuildConfiguration; 297 | buildSettings = { 298 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 299 | COMBINE_HIDPI_IMAGES = YES; 300 | DEVELOPMENT_TEAM = FRMDA3XRGC; 301 | INFOPLIST_FILE = Typewriter/Info.plist; 302 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; 303 | PRODUCT_BUNDLE_IDENTIFIER = de.christiantietze.Typewriter; 304 | PRODUCT_NAME = "$(TARGET_NAME)"; 305 | SWIFT_VERSION = 3.0; 306 | }; 307 | name = Release; 308 | }; 309 | /* End XCBuildConfiguration section */ 310 | 311 | /* Begin XCConfigurationList section */ 312 | 50CBA4771F0A35760092E8ED /* Build configuration list for PBXProject "Typewriter" */ = { 313 | isa = XCConfigurationList; 314 | buildConfigurations = ( 315 | 50CBA4891F0A35770092E8ED /* Debug */, 316 | 50CBA48A1F0A35770092E8ED /* Release */, 317 | ); 318 | defaultConfigurationIsVisible = 0; 319 | defaultConfigurationName = Release; 320 | }; 321 | 50CBA48B1F0A35770092E8ED /* Build configuration list for PBXNativeTarget "Typewriter" */ = { 322 | isa = XCConfigurationList; 323 | buildConfigurations = ( 324 | 50CBA48C1F0A35770092E8ED /* Debug */, 325 | 50CBA48D1F0A35770092E8ED /* Release */, 326 | ); 327 | defaultConfigurationIsVisible = 0; 328 | defaultConfigurationName = Release; 329 | }; 330 | /* End XCConfigurationList section */ 331 | }; 332 | rootObject = 50CBA4741F0A35760092E8ED /* Project object */; 333 | } 334 | -------------------------------------------------------------------------------- /Typewriter/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Typewriter 4 | // 5 | // Created by Christian Tietze on 03.07.17. 6 | // Copyright © 2017 Christian Tietze. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @NSApplicationMain 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | 14 | 15 | 16 | func applicationDidFinishLaunching(_ aNotification: Notification) { 17 | // Insert code here to initialize your application 18 | } 19 | 20 | func applicationWillTerminate(_ aNotification: Notification) { 21 | // Insert code here to tear down your application 22 | } 23 | 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Typewriter/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "size" : "16x16", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "size" : "16x16", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "size" : "32x32", 16 | "scale" : "1x" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "size" : "32x32", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "size" : "128x128", 26 | "scale" : "1x" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "size" : "128x128", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "size" : "256x256", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "size" : "256x256", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "size" : "512x512", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "size" : "512x512", 51 | "scale" : "2x" 52 | } 53 | ], 54 | "info" : { 55 | "version" : 1, 56 | "author" : "xcode" 57 | } 58 | } -------------------------------------------------------------------------------- /Typewriter/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 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 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | Default 511 | 512 | 513 | 514 | 515 | 516 | 517 | Left to Right 518 | 519 | 520 | 521 | 522 | 523 | 524 | Right to Left 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | Default 536 | 537 | 538 | 539 | 540 | 541 | 542 | Left to Right 543 | 544 | 545 | 546 | 547 | 548 | 549 | Right to Left 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | -------------------------------------------------------------------------------- /Typewriter/BottomOverscrollFlexibleTypewriterMode.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | public class BottomOverscrollFlexibleTypewriterMode: FlexibleTypewriterMode, DrawsTypewriterLineHighlight { 6 | 7 | public init() { } 8 | 9 | private(set) public var configuration: OverscrollConfiguration = OverscrollConfiguration.zero 10 | 11 | private var focusLockOffset: CGFloat = 0 12 | 13 | public func proposeFocusLockOffset(_ offset: CGFloat, block: (CGFloat, CGFloat) -> Void) { 14 | let oldValue = focusLockOffset 15 | focusLockOffset = offset 16 | block(oldValue, offset) 17 | } 18 | 19 | public func adjustOverscrolling( 20 | containerSize size: NSSize, 21 | lineHeight: CGFloat) { 22 | 23 | let halfScreen = floor((size.height - lineHeight) / 2) 24 | configuration.textContainerInset = NSSize(width: 0, height: halfScreen) 25 | configuration.overscrollTopOffset = halfScreen 26 | } 27 | 28 | public func typewriterScrolled(convertPoint point: NSPoint, scrollPosition: NSPoint) -> NSPoint { 29 | 30 | return point.applying(.init(translationX: 0, y: -focusLockOffset)) 31 | } 32 | 33 | 34 | // MARK: - Typewriter Highlight 35 | 36 | public var highlight: NSRect = NSRect.zero 37 | 38 | public func moveHighlight(rect: NSRect) { 39 | highlight = rect 40 | } 41 | 42 | public func drawHighlight(in rect: NSRect) { 43 | NSColor(calibratedRed: 1, green: 1, blue: 0, alpha: 1).set() 44 | NSRectFill(highlight) 45 | } 46 | 47 | public func hideHighlight() { 48 | highlight = NSRect.zero 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Typewriter/FinalFantasyTypewriterMode.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | public class FinalFantasyTypewriterMode: TypewriterMode, DrawsTypewriterLineHighlight { 6 | 7 | public init() { } 8 | 9 | private(set) public var configuration: OverscrollConfiguration = OverscrollConfiguration.zero 10 | 11 | private var focusLockThreshold: CGFloat = 0 12 | 13 | public func adjustOverscrolling( 14 | containerSize size: NSSize, 15 | lineHeight: CGFloat) { 16 | 17 | let halfScreen = floor((size.height - lineHeight) / 2) 18 | configuration.textContainerInset = NSSize(width: 0, height: halfScreen) 19 | configuration.overscrollTopOffset = halfScreen 20 | 21 | // Put focus lock threshold at 60% down the view 22 | self.focusLockThreshold = size.height * 0.6 23 | } 24 | 25 | public func typewriterScrolled(convertPoint point: NSPoint, scrollPosition: NSPoint) -> NSPoint { 26 | 27 | let currentY = scrollPosition.y 28 | let insertionY = point.y 29 | 30 | guard insertionY > currentY + focusLockThreshold else { return scrollPosition } 31 | 32 | return point.applying(.init(translationX: 0, y: -focusLockThreshold)) 33 | } 34 | 35 | 36 | // MARK: - Typewriter Highlight 37 | 38 | public var highlight: NSRect = NSRect.zero 39 | 40 | public func moveHighlight(rect: NSRect) { 41 | highlight = rect 42 | } 43 | 44 | public func drawHighlight(in rect: NSRect) { 45 | NSColor(calibratedRed: 1, green: 1, blue: 0, alpha: 1).set() 46 | NSRectFill(highlight) 47 | } 48 | 49 | public func hideHighlight() { 50 | highlight = NSRect.zero 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Typewriter/FixedTypewriterMode.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | public class FixedTypewriterMode: TypewriterMode, DrawsTypewriterLineHighlight { 6 | 7 | public init() { } 8 | 9 | private(set) public var configuration: OverscrollConfiguration = OverscrollConfiguration.zero 10 | 11 | private var focusLockOffset: CGFloat = 0 12 | 13 | public func adjustOverscrolling( 14 | containerSize size: NSSize, 15 | lineHeight: CGFloat) { 16 | 17 | let halfScreen = floor((size.height - lineHeight) / 2) 18 | configuration.textContainerInset = NSSize(width: 0, height: halfScreen) 19 | 20 | // Put focus lock 20% above the center 21 | let topFlush = size.height * 0.2 22 | configuration.overscrollTopOffset = topFlush 23 | self.focusLockOffset = halfScreen - topFlush 24 | } 25 | 26 | 27 | // MARK: - Typewriter Highlight 28 | 29 | public var highlight: NSRect { 30 | set { highlightWithOffset = newValue.offsetBy(dx: 0, dy: focusLockOffset) } 31 | get { return highlightWithOffset.offsetBy(dx: 0, dy: -focusLockOffset) } 32 | } 33 | fileprivate var highlightWithOffset: NSRect = NSRect.zero 34 | 35 | public func moveHighlight(rect: NSRect) { 36 | highlight = rect 37 | } 38 | 39 | public func drawHighlight(in rect: NSRect) { 40 | NSColor(calibratedRed: 1, green: 1, blue: 0, alpha: 1).set() 41 | NSRectFill(highlightWithOffset) 42 | } 43 | 44 | public func hideHighlight() { 45 | highlight = NSRect.zero 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Typewriter/FullOverscrollFlexibleTypewriterMode.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | /// Overscrolls in both directions. 6 | public class FullOverscrollFlexibleTypewriterMode: FlexibleTypewriterMode, DrawsTypewriterLineHighlight { 7 | 8 | let heightProportion: CGFloat 9 | 10 | /// - parameter heightProportion: Normalized fraction of the screen to overscroll. 11 | /// Defaults to `1.0` (100% overscrolling). 12 | public init(heightProportion: CGFloat = 1.0) { 13 | self.heightProportion = max(0.0, min(heightProportion, 1.0)) 14 | } 15 | 16 | private(set) public var configuration: OverscrollConfiguration = OverscrollConfiguration.zero 17 | 18 | private var focusLockOffset: CGFloat = 0 { 19 | didSet { 20 | configuration.textOriginInset = focusLockOffset 21 | } 22 | } 23 | 24 | public func proposeFocusLockOffset(_ offset: CGFloat, block: (CGFloat, CGFloat) -> Void) { 25 | let oldValue = focusLockOffset 26 | focusLockOffset = offset 27 | block(oldValue, offset) 28 | } 29 | 30 | /// Cached (top) inset to position the highlighter. 31 | private var overscrollInset: CGFloat = 0 32 | 33 | public func adjustOverscrolling( 34 | containerSize size: NSSize, 35 | lineHeight: CGFloat) { 36 | 37 | let screenPortion = floor((size.height - lineHeight) * heightProportion) 38 | // magic extra offset to ensure the last line is fully visible at 100% overscrolling 39 | - 2 * heightProportion 40 | configuration.textContainerInset = NSSize(width: 0, height: screenPortion) 41 | configuration.overscrollTopOffset = 0 42 | 43 | self.overscrollInset = screenPortion 44 | } 45 | 46 | 47 | // MARK: - Typewriter Highlight 48 | 49 | fileprivate var highlightWithOffset: NSRect = NSRect.zero 50 | 51 | public var highlight: NSRect { 52 | set { highlightWithOffset = newValue.offsetBy(dx: 0, dy: focusLockOffset) } 53 | get { return highlightWithOffset.offsetBy(dx: 0, dy: -focusLockOffset) } 54 | } 55 | 56 | public func moveHighlight(rect: NSRect) { 57 | highlight = rect.offsetBy(dx: 0, dy: overscrollInset) 58 | } 59 | 60 | public func drawHighlight(in rect: NSRect) { 61 | 62 | NSColor(calibratedRed: 1, green: 1, blue: 0, alpha: 1).set() 63 | NSRectFill(highlightWithOffset) 64 | } 65 | 66 | public func hideHighlight() { 67 | highlight = NSRect.zero 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Typewriter/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 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 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2017 Christian Tietze. All rights reserved. 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /Typewriter/TypewriterMode.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import Foundation 4 | 5 | public struct OverscrollConfiguration { 6 | 7 | public static var zero: OverscrollConfiguration { 8 | return OverscrollConfiguration( 9 | textContainerInset: NSSize.zero, 10 | textOriginInset: 0, 11 | overscrollTopOffset: 0) 12 | } 13 | 14 | public var textContainerInset: NSSize 15 | public var textOriginInset: CGFloat 16 | public var overscrollTopOffset: CGFloat 17 | 18 | public init( 19 | textContainerInset: NSSize, 20 | textOriginInset: CGFloat, 21 | overscrollTopOffset: CGFloat) { 22 | 23 | self.textContainerInset = textContainerInset 24 | self.textOriginInset = textOriginInset 25 | self.overscrollTopOffset = overscrollTopOffset 26 | } 27 | } 28 | 29 | public protocol TypewriterMode { 30 | 31 | var configuration: OverscrollConfiguration { get } 32 | func adjustOverscrolling(containerSize size: NSSize, lineHeight: CGFloat) 33 | func typewriterScrolled(convertPoint point: NSPoint, scrollPosition: NSPoint) -> NSPoint 34 | } 35 | 36 | extension TypewriterMode { 37 | public func typewriterScrolled(convertPoint point: NSPoint, scrollPosition: NSPoint) -> NSPoint { 38 | return point 39 | } 40 | } 41 | 42 | public protocol FlexibleTypewriterMode: TypewriterMode { 43 | func proposeFocusLockOffset(_ offset: CGFloat, block: (_ newLock: CGFloat, _ oldLock: CGFloat) -> Void) 44 | } 45 | 46 | public protocol DrawsTypewriterLineHighlight { 47 | 48 | var highlight: NSRect { get } 49 | func hideHighlight() 50 | func moveHighlight(rect: NSRect) 51 | func drawHighlight(in rect: NSRect) 52 | } 53 | 54 | extension DrawsTypewriterLineHighlight { 55 | public func hideHighlight() { 56 | moveHighlight(rect: NSRect.zero) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Typewriter/TypewriterTextStorage.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | public class CustomTextStorageBase: NSTextStorage { 6 | 7 | internal let content = NSTextStorage() 8 | 9 | public override var string: String { return content.string } 10 | 11 | public override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [String : Any] { 12 | return content.attributes(at: location, effectiveRange: range) 13 | } 14 | 15 | public override func replaceCharacters(in range: NSRange, with str: String) { 16 | beginEditing() 17 | content.replaceCharacters(in: range, with: str) 18 | self.edited(.editedCharacters, range: range, changeInLength: str.nsLength - range.length) 19 | endEditing() 20 | } 21 | 22 | public override func setAttributes(_ attrs: [String : Any]?, range: NSRange) { 23 | beginEditing() 24 | content.setAttributes(attrs, range: range) 25 | self.edited(.editedAttributes, range: range, changeInLength: 0) 26 | endEditing() 27 | } 28 | } 29 | 30 | fileprivate extension String { 31 | var nsLength: Int { 32 | return (self as NSString).length 33 | } 34 | } 35 | 36 | public protocol TypewriterTextStorageDelegate: class { 37 | /// Called to notify about the end of `endEditing()`. 38 | func typewriterTextStorageDidEndEditing(_ typewriterTextStorage: TypewriterTextStorage) 39 | } 40 | 41 | public class TypewriterTextStorage: CustomTextStorageBase { 42 | 43 | public weak var typewriterDelegate: TypewriterTextStorageDelegate? 44 | 45 | public override func endEditing() { 46 | super.endEditing() 47 | typewriterDelegate?.typewriterTextStorageDidEndEditing(self) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Typewriter/TypewriterTextView.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Christian Tietze. All rights reserved. Distributed under the MIT License. 2 | 3 | import AppKit 4 | 5 | public class TypewriterTextView: NSTextView { 6 | 7 | public var typewriterMode: TypewriterMode? = nil { 8 | didSet { 9 | guard let scrollView = self.enclosingScrollView else { return } 10 | relayoutTypewriterMode(scrollView: scrollView) 11 | } 12 | } 13 | 14 | /// Amount of pixels to nudge the text up, e.g. to be flush with the top edge. 15 | private var overscrollTopOffset: CGFloat { return typewriterMode?.configuration.overscrollTopOffset ?? 0 } 16 | private var textOriginInset: CGFloat { return typewriterMode?.configuration.textOriginInset ?? 0 } 17 | 18 | /// Cache to prevent coordinate conversion 19 | private var lastInsertionPointY: CGFloat? 20 | 21 | public func lockTypewriterDistance() { 22 | 23 | guard typewriterMode is FlexibleTypewriterMode else { return } 24 | 25 | let screenInsertionPointRect = firstRect(forCharacterRange: selectedRange(), actualRange: nil) 26 | guard screenInsertionPointRect.origin.y != lastInsertionPointY else { return } 27 | self.lastInsertionPointY = screenInsertionPointRect.origin.y 28 | 29 | guard let windowInsertionPointRect = window?.convertFromScreen(screenInsertionPointRect) else { return } 30 | guard let enclosingScrollView = self.enclosingScrollView else { return } 31 | 32 | let insertionPointRect = enclosingScrollView.convert(windowInsertionPointRect, from: nil) 33 | let distance = insertionPointRect.origin.y 34 | - enclosingScrollView.frame.origin.y 35 | // Take care of scroll view borders and content insets 36 | - enclosingScrollView.contentView.frame.origin.y 37 | let newOffset = ceil(-(textContainerInset.height - overscrollTopOffset) + distance) 38 | 39 | self.proposeFocusLockOffset(newOffset) 40 | } 41 | 42 | private func proposeFocusLockOffset(_ offset: CGFloat) { 43 | 44 | guard let flexibleTypewriterMode = typewriterMode as? FlexibleTypewriterMode else { return } 45 | 46 | flexibleTypewriterMode.proposeFocusLockOffset(offset) { newValue, oldValue in 47 | 48 | guard newValue != oldValue else { return } 49 | 50 | let difference = newValue - oldValue 51 | self.typewriterScroll(by: difference) 52 | self.fixInsertionPointPosition() 53 | self.moveHighlight(by: difference) 54 | } 55 | } 56 | 57 | public func unlockTypewriterDistance() { 58 | 59 | self.proposeFocusLockOffset(0) 60 | self.lastInsertionPointY = nil 61 | } 62 | 63 | /// After changing the `textContainerOrigin`, the insertion point sometimes 64 | /// remains where it was, not moving with the text. 65 | private func fixInsertionPointPosition() { 66 | self.setSelectedRange(selectedRange()) 67 | self.needsDisplay = true 68 | } 69 | 70 | public override var textContainerOrigin: NSPoint { 71 | let origin = super.textContainerOrigin 72 | return origin.applying(.init(translationX: 0, y: textOriginInset - overscrollTopOffset)) 73 | } 74 | 75 | public func relayoutTypewriterMode(scrollView: NSScrollView) { 76 | 77 | defer { forceLayoutWithNewInsets() } 78 | 79 | guard let typewriterMode = self.typewriterMode else { 80 | self.textContainerInset = .zero 81 | return 82 | } 83 | 84 | typewriterMode.adjustOverscrolling( 85 | containerSize: scrollView.contentView.documentVisibleRect.size, 86 | lineHeight: self.lineHeight) 87 | self.textContainerInset = typewriterMode.configuration.textContainerInset 88 | } 89 | 90 | /// Sends an "edited" message to the layout manager to make it adjust the size 91 | /// to fit the `textContainerInset`. Without doing this, it'll take until after 92 | /// the next edit by the user. 93 | private func forceLayoutWithNewInsets() { 94 | guard let textStorage = self.textStorage else { return } 95 | self.layoutManager?.processEditing( 96 | for: textStorage, 97 | edited: .editedAttributes, 98 | range: selectedRange(), 99 | changeInLength: 0, 100 | invalidatedRange: NSRange()) 101 | } 102 | 103 | internal var lineHeight: CGFloat { 104 | guard let font = self.font, 105 | let layoutManager = self.layoutManager 106 | else { return 0 } 107 | 108 | return layoutManager.defaultLineHeight(for: font) 109 | } 110 | 111 | public func typewriterScroll(by offset: CGFloat) { 112 | 113 | guard let visibleRect = enclosingScrollView?.contentView.documentVisibleRect else { return } 114 | let point = visibleRect.origin 115 | .applying(.init(translationX: 0, y: offset)) 116 | typewriterScroll(to: point) 117 | } 118 | 119 | public func typewriterScroll(to point: NSPoint) { 120 | 121 | guard let enclosingScrollView = self.enclosingScrollView else { return } 122 | let scrollPosition = enclosingScrollView.contentView.bounds.origin 123 | guard let scrolledPoint = typewriterMode?.typewriterScrolled(convertPoint: point, scrollPosition: scrollPosition) else { return } 124 | enclosingScrollView.contentView.bounds.origin = scrolledPoint 125 | 126 | // Fix jagged scrolling artifacts 127 | self.needsDisplay = true 128 | } 129 | 130 | 131 | // MARK: - Typewriter Highlight 132 | 133 | public var isDrawingTypingHighlight = true 134 | 135 | var highlightDrawer: DrawsTypewriterLineHighlight? { return typewriterMode as? DrawsTypewriterLineHighlight } 136 | 137 | public override func drawBackground(in rect: NSRect) { 138 | super.drawBackground(in: rect) 139 | 140 | guard isDrawingTypingHighlight else { return } 141 | highlightDrawer?.drawHighlight(in: rect) 142 | } 143 | 144 | public func hideHighlight() { 145 | highlightDrawer?.hideHighlight() 146 | } 147 | 148 | /// Move line highlight to `rect` in terms of the text view's coordinate system. 149 | /// Translates `rect` to take into account the `textContainer` position. 150 | public func moveHighlight(rectInTextView rect: NSRect) { 151 | guard let rectInSuperview = self.superview? 152 | .convert(rect, from: self) else { return } 153 | moveHighlight(rect: rectInSuperview) 154 | } 155 | 156 | private func moveHighlight(rect: NSRect) { 157 | guard isDrawingTypingHighlight else { return } 158 | highlightDrawer?.moveHighlight(rect: rect) 159 | } 160 | 161 | public func moveHighlight(by distance: CGFloat) { 162 | guard let highlight = highlightDrawer?.highlight else { return } 163 | moveHighlight(rect: highlight.offsetBy(dx: 0, dy: distance)) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Typewriter/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Typewriter 4 | // 5 | // Created by Christian Tietze on 03.07.17. 6 | // Copyright © 2017 Christian Tietze. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | var scrollViewContext: Void? 12 | 13 | enum TypewriterModeSetting { 14 | 15 | case fixed 16 | case overscrollFlexible 17 | case bottomOverscrollFlexible 18 | case finalFantasy 19 | 20 | var typewriterMode: TypewriterMode { 21 | switch self { 22 | case .fixed: return FixedTypewriterMode() 23 | case .overscrollFlexible: return FullOverscrollFlexibleTypewriterMode(heightProportion: 1) 24 | case .bottomOverscrollFlexible: return BottomOverscrollFlexibleTypewriterMode() 25 | case .finalFantasy: return FinalFantasyTypewriterMode() 26 | } 27 | } 28 | 29 | init?(fromTag tag: Int) { 30 | switch tag { 31 | case 1: self = .fixed 32 | case 2: self = .overscrollFlexible 33 | case 3: self = .bottomOverscrollFlexible 34 | case 4: self = .finalFantasy 35 | default: return nil 36 | } 37 | } 38 | } 39 | 40 | class ViewController: NSViewController, NSTextStorageDelegate, TypewriterTextStorageDelegate, NSTextViewDelegate { 41 | 42 | @IBOutlet weak var scrollView: NSScrollView! 43 | @IBOutlet weak var clipView: NSClipView! 44 | @IBOutlet var textView: TypewriterTextView! 45 | 46 | var typewriterModeSetting: TypewriterModeSetting? { 47 | didSet { 48 | textView.typewriterMode = typewriterModeSetting?.typewriterMode 49 | 50 | if isInTypewriterMode { 51 | textView.lockTypewriterDistance() 52 | alignScrollingToInsertionPoint() 53 | } else { 54 | textView.unlockTypewriterDistance() 55 | textView.hideHighlight() 56 | } 57 | 58 | textView.needsDisplay = true 59 | } 60 | } 61 | var isInTypewriterMode: Bool { return typewriterModeSetting != nil } 62 | 63 | @IBAction func changeTypewriterMode(_ sender: Any?) { 64 | guard let menuItem = sender as? NSMenuItem else { return } 65 | self.typewriterModeSetting = TypewriterModeSetting(fromTag: menuItem.tag) 66 | } 67 | 68 | override func viewDidLoad() { 69 | super.viewDidLoad() 70 | 71 | let textStorage = TypewriterTextStorage() 72 | textStorage.typewriterDelegate = self 73 | textStorage.delegate = self 74 | textView.layoutManager?.replaceTextStorage(textStorage) 75 | 76 | // Without a custom layout manager, some errors do not surface, so it's 77 | // a good idea to keep this useless replacement during development. 78 | textView.textContainer?.replaceLayoutManager(NSLayoutManager()) 79 | 80 | textView.string = (try? String(contentsOf: URL(fileURLWithPath: "/Users/ctm/Archiv/§ O reswift.md"))) ?? "" 81 | 82 | scrollView.addObserver(self, forKeyPath: "frame", options: [.new, .initial], context: &scrollViewContext) 83 | } 84 | 85 | deinit { 86 | scrollView.removeObserver(self, forKeyPath: "frame") 87 | } 88 | 89 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 90 | 91 | guard 92 | context == &scrollViewContext, 93 | let scrollView = object as? NSScrollView 94 | else { return } 95 | 96 | scrollViewDidResize(scrollView) 97 | } 98 | 99 | func scrollViewDidResize(_ scrollView: NSScrollView) { 100 | textView.relayoutTypewriterMode(scrollView: scrollView) 101 | } 102 | 103 | // MARK: - Typewriter Scrolling 104 | 105 | private var needsTypewriterDistanceReset = false 106 | 107 | /// Indicates if the text storage is currently processing changes 108 | /// and current text view changes reflect programmatic adjustments. 109 | private var isProcessingEdit = false 110 | private var isUserInitiated: Bool { return !isProcessingEdit } 111 | 112 | func textViewDidChangeSelection(_ notification: Notification) { 113 | guard isInTypewriterMode else { return } 114 | guard isUserInitiated else { return } 115 | needsTypewriterDistanceReset = true 116 | } 117 | 118 | 119 | // MARK: Preparation 120 | 121 | func textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { 122 | 123 | guard isInTypewriterMode else { return } 124 | isProcessingEdit = true 125 | } 126 | 127 | func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { 128 | 129 | guard isInTypewriterMode else { return } 130 | scheduleScrollingToInsertionPoint() 131 | } 132 | 133 | fileprivate var shouldScrollToInsertionPoint = false 134 | fileprivate func scheduleScrollingToInsertionPoint() { 135 | 136 | shouldScrollToInsertionPoint = true 137 | } 138 | 139 | 140 | // MARK: Execution 141 | 142 | func typewriterTextStorageDidEndEditing(_ typewriterTextStorage: TypewriterTextStorage) { 143 | 144 | processScrollingToInsertionPoint() 145 | } 146 | 147 | fileprivate func processScrollingToInsertionPoint() { 148 | 149 | guard shouldScrollToInsertionPoint else { return } 150 | defer { shouldScrollToInsertionPoint = false } 151 | 152 | alignScrollingToInsertionPoint() 153 | isProcessingEdit = false 154 | } 155 | 156 | fileprivate func alignScrollingToInsertionPoint() { 157 | 158 | guard let layoutManager = textView.layoutManager else { return } 159 | 160 | if needsTypewriterDistanceReset { 161 | textView.lockTypewriterDistance() 162 | needsTypewriterDistanceReset = false 163 | } 164 | 165 | let lineRect = insertionPointLineRect( 166 | textView: textView, 167 | layoutManager: layoutManager) 168 | textView.moveHighlight(rectInTextView: lineRect) 169 | textView.typewriterScroll(to: lineRect.origin) 170 | } 171 | } 172 | 173 | func insertionPointLineRect(textView: NSTextView, layoutManager: NSLayoutManager) -> NSRect { 174 | 175 | let location = textView.selectedRange().location 176 | 177 | if location >= layoutManager.numberOfGlyphs { 178 | let extraLineFragmentRect = layoutManager.extraLineFragmentRect 179 | if extraLineFragmentRect != NSRect.zero, 180 | // When typing at the very end, sometimes the origin 181 | // is -(lineHeight) for no apparent reason. 182 | extraLineFragmentRect.origin.y >= 0 { 183 | return layoutManager.extraLineFragmentRect 184 | } 185 | 186 | // Sometimes, `extraLineFragmentRect` is .zero but you did just hit return ¯\_(ツ)_/¯ 187 | if textView.string?.characters.last == "\n" { 188 | let lineRect = lineFragmentRect(location: location, layoutManager: layoutManager) 189 | return lineRect.offsetBy(dx: 0, dy: lineRect.height) 190 | } 191 | } 192 | 193 | return lineFragmentRect(location: location, layoutManager: layoutManager) 194 | } 195 | 196 | func lineFragmentRect(location: Int, layoutManager: NSLayoutManager) -> NSRect { 197 | 198 | let insertionPointGlyphIndex = min(location, layoutManager.numberOfGlyphs - 1) 199 | return layoutManager.lineFragmentRect(forGlyphAt: insertionPointGlyphIndex, effectiveRange: nil) 200 | } 201 | --------------------------------------------------------------------------------