Skip to content

Commit

Permalink
Line number support (#275)
Browse files Browse the repository at this point in the history
* Added ability to show line numbers in Editor

* Updated snapshot tests

* Added logic to render line number for initial blank line

* Fixed yPostion for line numbers with linespacing and paraSpacing

* Added failing test for dynamic gutter width to be fixed later
  • Loading branch information
rajdeep authored Feb 9, 2024
1 parent f04eb43 commit 3d00fe0
Show file tree
Hide file tree
Showing 85 changed files with 483 additions and 0 deletions.
12 changes: 12 additions & 0 deletions Proton/Proton.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
1B183D8E23CEE9BA00AE83E5 /* AttributesEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B183D8D23CEE9BA00AE83E5 /* AttributesEncoding.swift */; };
1B183D9223CEEED900AE83E5 /* EditorContentEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B183D9023CEEEC400AE83E5 /* EditorContentEncoderTests.swift */; };
1B1C3727244BE0D60028E1ED /* EditorViewContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1C3726244BE0D60028E1ED /* EditorViewContextTests.swift */; };
1B21AD052B74604C00EBC0BF /* EditorLineNumberProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */; };
1B21AD072B74614C00EBC0BF /* LineNumberFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */; };
1B21AD0A2B7462AD00EBC0BF /* MockLineNumberProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */; };
1B238D6E2456A40200BF49D5 /* NullRichTextEditorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B238D6D2456A40200BF49D5 /* NullRichTextEditorContext.swift */; };
1B2BC0D823CF17E300407DEE /* EditorContentTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2BC0D723CF17E300407DEE /* EditorContentTransformerTests.swift */; };
1B2BC0DD23CF18C700407DEE /* EditorContentDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2BC0DB23CF18C100407DEE /* EditorContentDecoding.swift */; };
Expand Down Expand Up @@ -189,6 +192,9 @@
1B183D8D23CEE9BA00AE83E5 /* AttributesEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributesEncoding.swift; sourceTree = "<group>"; };
1B183D9023CEEEC400AE83E5 /* EditorContentEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentEncoderTests.swift; sourceTree = "<group>"; };
1B1C3726244BE0D60028E1ED /* EditorViewContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorViewContextTests.swift; sourceTree = "<group>"; };
1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorLineNumberProvider.swift; sourceTree = "<group>"; };
1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineNumberFormatting.swift; sourceTree = "<group>"; };
1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLineNumberProvider.swift; sourceTree = "<group>"; };
1B238D6D2456A40200BF49D5 /* NullRichTextEditorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullRichTextEditorContext.swift; sourceTree = "<group>"; };
1B2BC0D723CF17E300407DEE /* EditorContentTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentTransformerTests.swift; sourceTree = "<group>"; };
1B2BC0DB23CF18C100407DEE /* EditorContentDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentDecoding.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -448,6 +454,7 @@
1B45CDCF23C007AF001EB196 /* RichTextView.swift */,
1B9B744C2A8B09EF00FF4E92 /* GestureRegognizerDelegateOverride.swift */,
1BD21553246951090000BCE2 /* LayoutManager.swift */,
1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */,
1B45CDD123C00856001EB196 /* TextContainer.swift */,
1B7A985823C484BC00C34B14 /* RichTextViewDelegate.swift */,
1B975AFE23CD454700EC410C /* RichTextViewContext.swift */,
Expand All @@ -459,6 +466,7 @@
1B30A35F2489CE3E00FA1D48 /* ListFormattingProvider.swift */,
1BFDC80E254A9BFC00BD83BD /* ListParser.swift */,
1B7C18892AAEC078005457D9 /* AsyncTaskScheduler.swift */,
1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */,
);
path = Core;
sourceTree = "<group>";
Expand Down Expand Up @@ -855,6 +863,7 @@
1B8BE91E23C71E8A00353B17 /* MockEditorViewDelegate.swift */,
1BD993C323CACCE100563ACB /* MockAttachment.swift */,
1B6FB1892ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift */,
1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */,
);
path = Mocks;
sourceTree = "<group>";
Expand Down Expand Up @@ -1016,6 +1025,7 @@
buildActionMask = 2147483647;
files = (
1B238D6E2456A40200BF49D5 /* NullRichTextEditorContext.swift in Sources */,
1B21AD072B74614C00EBC0BF /* LineNumberFormatting.swift in Sources */,
1B45CDBE23BF125D001EB196 /* NSAttributedString+ContentTypes.swift in Sources */,
1B7A985723C4828A00C34B14 /* RichTextEditorContext.swift in Sources */,
1BC0AA64284DF918004B8862 /* GridConfiguration.swift in Sources */,
Expand All @@ -1030,6 +1040,7 @@
1B4B60CA247FC51E002B63CF /* ListCommand.swift in Sources */,
1B7C76AB2608A489006618AC /* BoldCommand.swift in Sources */,
1B7C188C2AB17621005457D9 /* SynchronizedArray.swift in Sources */,
1B21AD052B74604C00EBC0BF /* EditorLineNumberProvider.swift in Sources */,
1BBAC3CF23CD5A1B0088A1C8 /* UITextRangeExtensions.swift in Sources */,
1BFDC80F254A9BFC00BD83BD /* ListParser.swift in Sources */,
1B7C76AC2608A489006618AC /* ItalicsCommand.swift in Sources */,
Expand Down Expand Up @@ -1155,6 +1166,7 @@
1B30A3622489DC7B00FA1D48 /* MockListFormattingProvider.swift in Sources */,
1BD185C4284C33B0001F4FBC /* GridViewSnapshotTests.swift in Sources */,
1B6DE9D923C5940B007F9859 /* EditorCommandSnapshotTests.swift in Sources */,
1B21AD0A2B7462AD00EBC0BF /* MockLineNumberProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 2 additions & 0 deletions Proton/Sources/Swift/Base/AutogrowingTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class AutogrowingTextView: UITextView {
heightAnchorConstraint
])
}
//TODO: enable only when line numbering is turned on
contentMode = .redraw
}

required init?(coder: NSCoder) {
Expand Down
29 changes: 29 additions & 0 deletions Proton/Sources/Swift/Core/EditorLineNumberProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// EditorLineNumberProvider.swift
// Proton
//
// Created by Rajdeep Kwatra on 8/2/2024.
// Copyright © 2023 Rajdeep Kwatra. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import UIKit

/// Describes an object capable of providing numbers to be displayed when `isLineNumbersEnabled` is set to `true` in `EditorView`
public protocol LineNumberProvider: AnyObject {
var lineNumberWrappingMarker: String? { get }

func lineNumberString(for index: Int) -> String?
}
75 changes: 75 additions & 0 deletions Proton/Sources/Swift/Core/LayoutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ protocol LayoutManagerDelegate: AnyObject {
var textContainerInset: UIEdgeInsets { get }

var listLineFormatting: LineFormatting { get }

var isLineNumbersEnabled: Bool { get }
var lineNumberFormatting: LineNumberFormatting { get }
var lineNumberWrappingMarker: String? { get }

func listMarkerForItem(at index: Int, level: Int, previousLevel: Int, attributeValue: Any?) -> ListLineMarker
func lineNumberString(for index: Int) -> String?
}

class LayoutManager: NSLayoutManager {
Expand Down Expand Up @@ -270,11 +275,33 @@ class LayoutManager: NSLayoutManager {
return stringRect
}

private func rectForLineNumbers(markerSize: CGSize, rect: CGRect, width: CGFloat) -> CGRect {
let topInset = layoutManagerDelegate?.textContainerInset.top ?? 0
let spacerRect = CGRect(origin: CGPoint(x: 0, y: topInset), size: CGSize(width: width, height: rect.height))

let scaleFactor = markerSize.height / spacerRect.height
var markerSizeToUse = markerSize
// Resize maintaining aspect ratio if bullet height is more than available line height
if scaleFactor > 1 {
markerSizeToUse = CGSize(width: markerSize.width/scaleFactor, height: markerSize.height/scaleFactor)
}

let trailingPadding: CGFloat = 2
let yPos = topInset + rect.minY
let stringRect = CGRect(origin: CGPoint(x: spacerRect.maxX - markerSizeToUse.width - trailingPadding, y: yPos), size: markerSizeToUse)

// debugRect(rect: spacerRect, color: .blue)
// debugRect(rect: stringRect, color: .red)

return stringRect
}

override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
guard let textStorage = textStorage,
let currentCGContext = UIGraphicsGetCurrentContext()
else { return }
currentCGContext.saveGState()

let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
textStorage.enumerateAttribute(.backgroundStyle, in: characterRange) { attr, bgStyleRange, _ in
Expand Down Expand Up @@ -321,6 +348,54 @@ class LayoutManager: NSLayoutManager {
drawBackground(backgroundStyle: backgroundStyle, rects: rects, currentCGContext: currentCGContext)
}
}
drawLineNumbers(textStorage: textStorage, currentCGContext: currentCGContext)
currentCGContext.restoreGState()
}

private func drawLineNumbers(textStorage: NSTextStorage, currentCGContext: CGContext) {
var lineNumber = 1
guard layoutManagerDelegate?.isLineNumbersEnabled == true,
let lineNumberFormatting = layoutManagerDelegate?.lineNumberFormatting else { return }

let lineNumberWrappingMarker = layoutManagerDelegate?.lineNumberWrappingMarker
enumerateLineFragments(forGlyphRange: textStorage.fullRange) { [weak self] rect, usedRect, _, range, _ in
guard let self else { return }
let paraRange = self.textStorage?.mutableString.paragraphRange(for: range).firstCharacterRange
let lineNumberToDisplay = layoutManagerDelegate?.lineNumberString(for: lineNumber) ?? "\(lineNumber)"

if range.location == paraRange?.location {
self.drawLineNumber(lineNumber: lineNumberToDisplay, rect: rect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
lineNumber += 1
} else if let lineNumberWrappingMarker {
self.drawLineNumber(lineNumber: lineNumberWrappingMarker, rect: rect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
}
}

// Draw line number for additional new line with \n, if exists
drawLineNumber(lineNumber: "\(lineNumber)", rect: extraLineFragmentRect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
}

func drawLineNumber(lineNumber: String, rect: CGRect, lineNumberFormatting: LineNumberFormatting, currentCGContext: CGContext) {
let gutterWidth = lineNumberFormatting.gutter.width
let attributes = lineNumberAttributes(lineNumberFormatting: lineNumberFormatting)
let text = NSAttributedString(string: "\(lineNumber)", attributes: attributes)
let markerSize = text.boundingRect(with: .zero, options: [], context: nil).integral.size
var markerRect = self.rectForLineNumbers(markerSize: markerSize, rect: rect, width: gutterWidth)
let listMarkerImage = self.generateBitmap(string: text, rect: markerRect)
listMarkerImage.draw(at: markerRect.origin)
}

private func lineNumberAttributes(lineNumberFormatting: LineNumberFormatting) -> [NSAttributedString.Key: Any] {
let font = lineNumberFormatting.font
let textColor = lineNumberFormatting.textColor
let paraStyle = NSMutableParagraphStyle()
paraStyle.alignment = .right

return [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: textColor,
NSAttributedString.Key.paragraphStyle: paraStyle
]
}

private func drawBackground(backgroundStyle: BackgroundStyle, rects: [CGRect], currentCGContext: CGContext) {
Expand Down
53 changes: 53 additions & 0 deletions Proton/Sources/Swift/Core/LineNumberFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// LineNumberFormatting.swift
// Proton
//
// Created by Rajdeep Kwatra on 8/2/2023.
// Copyright © 2023 Rajdeep Kwatra. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import UIKit

public struct Gutter {
public let lineWidth: CGFloat
public let lineColor: UIColor?
public let width: CGFloat
public let backgroundColor: UIColor

init(width: CGFloat, backgroundColor: UIColor, lineColor: UIColor? = nil, lineWidth: CGFloat = 1) {
self.width = width
self.lineColor = lineColor
self.lineWidth = (lineColor != nil) ? lineWidth : 0
self.backgroundColor = backgroundColor
}
}

public struct LineNumberFormatting {

public static let `default` = LineNumberFormatting(
textColor: .darkGray, font: .monospacedDigitSystemFont(ofSize: 17, weight: .light),
gutter: Gutter(width: 30, backgroundColor: .lightGray))

public let textColor: UIColor
public let font: UIFont
public let gutter: Gutter

init(textColor: UIColor, font: UIFont, gutter: Gutter) {
self.textColor = textColor
self.font = font
self.gutter = gutter
}
}
95 changes: 95 additions & 0 deletions Proton/Sources/Swift/Core/RichTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class RichTextView: AutogrowingTextView {
weak var richTextViewDelegate: RichTextViewDelegate?
weak var richTextViewListDelegate: RichTextViewListDelegate?
weak var richTextScrollViewDelegate: UIScrollViewDelegate?
weak var lineNumberProvider: LineNumberProvider?

private var delegateOverrides = [GestureRecognizerDelegateOverride]()

Expand Down Expand Up @@ -85,6 +86,87 @@ class RichTextView: AutogrowingTextView {
}
}

var lineNumberFormatting = LineNumberFormatting.default {
didSet {
let gutterOffset = lineNumberFormatting.gutter.width + lineNumberFormatting.gutter.lineWidth
let adjustedLeftInset = isLineNumbersEnabled ? (gutterOffset + textContainerInset.left - oldValue.gutter.width): nil

textContainerInset = UIEdgeInsets(
top: textContainerInset.top,
left: adjustedLeftInset ?? textContainerInset.left,
bottom: textContainerInset.bottom,
right: textContainerInset.right
)
setNeedsDisplay()
}
}

var isLineNumbersEnabled = false {
didSet {
let gutterOffset = lineNumberFormatting.gutter.width + lineNumberFormatting.gutter.lineWidth

let adjustedLeftInset: CGFloat
switch (oldValue, isLineNumbersEnabled) {
case (false, true):
adjustedLeftInset = gutterOffset + textContainerInset.left
case (true, false):
adjustedLeftInset = textContainerInset.left - gutterOffset
default:
adjustedLeftInset = textContainerInset.left
}

textContainerInset = UIEdgeInsets(
top: textContainerInset.top,
left: adjustedLeftInset,
bottom: textContainerInset.bottom,
right: textContainerInset.right
)
setNeedsDisplay()
}
}

override func draw(_ rect: CGRect) {
guard isLineNumbersEnabled,
let currentCGContext = UIGraphicsGetCurrentContext() else {
super.draw(rect)
return
}

let height = max(contentSize.height, bounds.height)
let rect = CGRect(x: 0, y: 0, width: lineNumberFormatting.gutter.width, height: height)
let rectanglePath = UIBezierPath(rect: rect)

currentCGContext.saveGState()
currentCGContext.addPath(rectanglePath.cgPath)

if let lineColor = lineNumberFormatting.gutter.lineColor {
currentCGContext.setStrokeColor(lineColor.cgColor)
currentCGContext.setLineWidth(lineNumberFormatting.gutter.lineWidth)
currentCGContext.drawPath(using: .stroke)
}

currentCGContext.setFillColor(lineNumberFormatting.gutter.backgroundColor.cgColor)
currentCGContext.fill(rect)

// Draw line number if textView is empty
if let layoutManager = layoutManager as? LayoutManager,
attributedText.length == 0 {
let lineNumberToDisplay = lineNumberString(for: 1) ?? "1"
let width = lineNumberFormatting.gutter.width
let height = defaultFont.lineHeight
layoutManager.drawLineNumber(lineNumber: lineNumberToDisplay, rect: CGRect(origin: .zero, size: CGSize(width: width, height: height)), lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
}

currentCGContext.restoreGState()

super.draw(rect)
}

func drawDefaultLineNumberIfRequired() {
guard isLineNumbersEnabled else { return }
draw(CGRect(origin: .zero, size: contentSize))
}

override var selectedTextRange: UITextRange? {
didSet{
let old = oldValue?.toNSRange(in: self)
Expand Down Expand Up @@ -503,6 +585,10 @@ class RichTextView: AutogrowingTextView {
return
}
setupPlaceholder()
if isLineNumbersEnabled {
//TODO: else use default
contentMode = .redraw
}
}

func attributeValue(at location: CGPoint, for attribute: NSAttributedString.Key) -> Any? {
Expand Down Expand Up @@ -725,10 +811,15 @@ extension RichTextView: TextStorageDelegate {

func textStorage(_ textStorage: PRTextStorage, edited actions: NSTextStorage.EditActions, in editedRange: NSRange, changeInLength delta: Int) {
updatePlaceholderVisibility()
drawDefaultLineNumberIfRequired()
}
}

extension RichTextView: LayoutManagerDelegate {
var lineNumberWrappingMarker: String? {
lineNumberProvider?.lineNumberWrappingMarker
}

var listLineFormatting: LineFormatting {
return richTextViewListDelegate?.listLineFormatting ?? RichTextView.defaultListLineFormatting
}
Expand All @@ -737,6 +828,10 @@ extension RichTextView: LayoutManagerDelegate {
return defaultTextFormattingProvider?.paragraphStyle
}

func lineNumberString(for index: Int) -> String? {
lineNumberProvider?.lineNumberString(for: index)
}

func listMarkerForItem(at index: Int, level: Int, previousLevel: Int, attributeValue: Any?) -> ListLineMarker {
let font = UIFont.preferredFont(forTextStyle: .body)
let defaultValue = NSAttributedString(string: "*", attributes: [.font: font])
Expand Down
Loading

0 comments on commit 3d00fe0

Please sign in to comment.