Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactively autoincrement list when commenting #53

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions MessageViewController.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; };
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */; };
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */; };
6ED7A797206FD89E00B11C0E /* InteractiveTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */; };
6ED7A799206FD8F600B11C0E /* InteractiveTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -58,6 +60,8 @@
29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+iOS11.swift"; sourceTree = "<group>"; };
38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+ReplaceRange.swift"; sourceTree = "<group>"; };
38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+HighlightingTests.swift"; sourceTree = "<group>"; };
6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveTextFormatter.swift; sourceTree = "<group>"; };
6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveTextFormatterTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -114,6 +118,7 @@
2904821E1FED90340053978C /* UITextView+Prefixes.swift */,
38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */,
29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */,
6ED7A796206FD89E00B11C0E /* InteractiveTextFormatter.swift */,
);
path = MessageViewController;
sourceTree = "<group>";
Expand All @@ -125,6 +130,7 @@
29CC293A1FF4266D006B6DE7 /* MessageViewControllerTests.swift */,
29CC29431FF4267F006B6DE7 /* String+WordAtRangeTests.swift */,
38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */,
6ED7A798206FD8F600B11C0E /* InteractiveTextFormatterTests.swift */,
);
path = MessageViewControllerTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -246,6 +252,7 @@
290482261FED90340053978C /* UIButton+BottomHeightOffset.swift in Sources */,
290482251FED90340053978C /* MessageViewDelegate.swift in Sources */,
290482201FED90340053978C /* MessageViewController.swift in Sources */,
6ED7A797206FD89E00B11C0E /* InteractiveTextFormatter.swift in Sources */,
29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */,
29792B151FFAE7FC007A0C57 /* MessageAutocompleteController.swift in Sources */,
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */,
Expand All @@ -261,6 +268,7 @@
files = (
29CC293B1FF4266D006B6DE7 /* MessageViewControllerTests.swift in Sources */,
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */,
6ED7A799206FD8F600B11C0E /* InteractiveTextFormatterTests.swift in Sources */,
29CC29441FF4267F006B6DE7 /* String+WordAtRangeTests.swift in Sources */,
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */,
);
Expand Down
44 changes: 44 additions & 0 deletions MessageViewController/InteractiveTextFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// InteractiveTextFormatter.swift
// MessageViewController
//
// Created by Viktoras Laukevičius on 31/03/2018.
// Copyright © 2018 Ryan Nystrom. All rights reserved.
//

import Foundation

private extension NSAttributedString {
func replacingCharactersWithReplacementsBeginning(in range: NSRange, with: String, attributes: [NSAttributedStringKey : Any]) -> (NSAttributedString, Int) {
let attrStr = self.replacingCharacters(in: range, with: NSAttributedString(string: with, attributes: attributes))
return (attrStr, range.upperBound + with.count)
}
}

internal final class InteractiveTextFormatter {

func applying(change: String, in attrText: NSAttributedString, in range: NSRange) -> (NSAttributedString, Int)? {
// currently supports only if a single (new line) character is typed
guard change == "\n" else { return nil }

let rangeBegin = attrText.string.index(attrText.string.startIndex, offsetBy: range.lowerBound)
let textUpToChangeLoc = attrText.string.prefix(upTo: rangeBegin)
guard let lastLine = textUpToChangeLoc.components(separatedBy: "\n").last, lastLine.count != 0 else { return nil }

// it's safe to get attributes of text at specified location because
// not empty components list ensures that the text is not empty
let textAttrs = attrText.attributes(at: range.lowerBound - 1, effectiveRange: nil)

if let unorderedPrefix = ["* ", "- ", "+ "].first(where: lastLine.starts) {
return attrText.replacingCharactersWithReplacementsBeginning(in: range, with: "\n\(unorderedPrefix)", attributes: textAttrs)
} else if let candidate = lastLine.components(separatedBy: " ").first, candidate.hasSuffix("."), let intVal = Int(candidate.prefix(candidate.count - 1)) {
guard intVal &+ 1 > intVal else {
// wow, someone created a really long list
return nil
}
return attrText.replacingCharactersWithReplacementsBeginning(in: range, with: "\n\(intVal + 1). ", attributes: textAttrs)
} else {
return nil
}
}
}
12 changes: 11 additions & 1 deletion MessageViewController/MessageTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ open class MessageTextView: UITextView, UITextViewDelegate {

private var listeners: NSHashTable<AnyObject> = NSHashTable.weakObjects()

private let interactiveFormatter = InteractiveTextFormatter()

open override var delegate: UITextViewDelegate? {
get { return self }
set {}
Expand Down Expand Up @@ -135,7 +137,15 @@ open class MessageTextView: UITextView, UITextViewDelegate {

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
enumerateListeners { $0.willChangeRange(textView: self, to: range) }
return true

if let (newAttrText, insertionEnd) = interactiveFormatter.applying(change: text, in: textView.attributedText, in: range) {
textView.attributedText = newAttrText
let selectionPos = textView.position(from: textView.beginningOfDocument, offset: insertionEnd) ?? textView.beginningOfDocument
textView.selectedTextRange = textView.textRange(from: selectionPos, to: selectionPos)
return false
} else {
return true
}
}

}
100 changes: 100 additions & 0 deletions MessageViewControllerTests/InteractiveTextFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// InteractiveTextFormatterTests.swift
// MessageViewControllerTests
//
// Created by Viktoras Laukevičius on 31/03/2018.
// Copyright © 2018 Ryan Nystrom. All rights reserved.
//

import XCTest
@testable import MessageViewController

private func generateList(withDelimiter d: String) -> String {
return "\(d) Item A\n\(d) Item B"
}

class InteractiveTextFormatterTests: XCTestCase {

var formatter: InteractiveTextFormatter!

override func setUp() {
super.setUp()
formatter = InteractiveTextFormatter()
}

override func tearDown() {
formatter = nil
super.tearDown()
}

func test_continuesNumberedList_whenAtTheEnd() {
let str = "1. Item A\n2. Item B"
let attrStr = NSAttributedString(string: str)
let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0
XCTAssertEqual(text?.string, "\(str)\n3. ")
}

func test_ignoresIncrement_whenReallyLongList() {
let maxInt = Int.max
let str = "\(maxInt - 1). Item A \n\(maxInt). Item B"
let attrStr = NSAttributedString(string: str)
let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0
XCTAssertNil(text)
}

func test_continuesNumberedList_whenInTheMiddle() {
let str1 = "1. Item A \n2. Item B"
let str2 = "\nContinues..."
let attrStr = NSAttributedString(string: "\(str1)\(str2)")
let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str1.count, length: 0))?.0
XCTAssertEqual(text?.string, "\(str1)\n3. \(str2)")
}

func test_continuesList_whenUsedAsterisks() {
let str = generateList(withDelimiter: "*")
let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0
XCTAssertEqual(text?.string, "\(str)\n* ")
}

func test_continuesList_whenUsedMinuses() {
let str = generateList(withDelimiter: "-")
let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0
XCTAssertEqual(text?.string, "\(str)\n- ")
}

func test_continuesList_whenUsedPuses() {
let str = generateList(withDelimiter: "+")
let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0
XCTAssertEqual(text?.string, "\(str)\n+ ")
}

func test_preservesAttributes_whenTextPrefilled() {
let str = generateList(withDelimiter: "-")
let attrs: [NSAttributedStringKey : AnyHashable] = [NSAttributedStringKey.baselineOffset : 3]
let attrStr = NSAttributedString(string: str, attributes: attrs)
let text = formatter.applying(change: "\n", in: attrStr, in: NSRange(location: str.count, length: 0))?.0
let resultAttrs = text?.attributes(at: str.count + 2, effectiveRange: nil) as? [NSAttributedStringKey : AnyHashable]
XCTAssertEqual(resultAttrs!, attrs)
}

func test_adjustsSelectionPosition_whenTextAppended() {
let str = generateList(withDelimiter: "-")
let selectionBeginning = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.1
let appended = "\n- "
XCTAssertEqual(selectionBeginning, str.count + appended.count)
}

func test_adjustsSelectionPosition_whenTextInserted() {
let str1 = generateList(withDelimiter: "-")
let str2 = "\nContinues..."
let selectionBeginning = formatter.applying(change: "\n", in: NSAttributedString(string: "\(str1)\(str2)"), in: NSRange(location: str1.count, length: 0))?.1
let inserted = "\n- "
XCTAssertEqual(selectionBeginning, str1.count + inserted.count)
}

func test_notContinuesList_whenLineNotListItem() {
let str = generateList(withDelimiter: "-") + "\nContinues..."
let text = formatter.applying(change: "\n", in: NSAttributedString(string: str), in: NSRange(location: str.count, length: 0))?.0
XCTAssertNil(text)
}
}