Skip to content

Commit 3d00fe0

Browse files
authored
Line number support (#275)
* 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
1 parent f04eb43 commit 3d00fe0

File tree

85 files changed

+483
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+483
-0
lines changed

Proton/Proton.xcodeproj/project.pbxproj

+12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
1B183D8E23CEE9BA00AE83E5 /* AttributesEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B183D8D23CEE9BA00AE83E5 /* AttributesEncoding.swift */; };
1818
1B183D9223CEEED900AE83E5 /* EditorContentEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B183D9023CEEEC400AE83E5 /* EditorContentEncoderTests.swift */; };
1919
1B1C3727244BE0D60028E1ED /* EditorViewContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1C3726244BE0D60028E1ED /* EditorViewContextTests.swift */; };
20+
1B21AD052B74604C00EBC0BF /* EditorLineNumberProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */; };
21+
1B21AD072B74614C00EBC0BF /* LineNumberFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */; };
22+
1B21AD0A2B7462AD00EBC0BF /* MockLineNumberProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */; };
2023
1B238D6E2456A40200BF49D5 /* NullRichTextEditorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B238D6D2456A40200BF49D5 /* NullRichTextEditorContext.swift */; };
2124
1B2BC0D823CF17E300407DEE /* EditorContentTransformerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2BC0D723CF17E300407DEE /* EditorContentTransformerTests.swift */; };
2225
1B2BC0DD23CF18C700407DEE /* EditorContentDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2BC0DB23CF18C100407DEE /* EditorContentDecoding.swift */; };
@@ -189,6 +192,9 @@
189192
1B183D8D23CEE9BA00AE83E5 /* AttributesEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributesEncoding.swift; sourceTree = "<group>"; };
190193
1B183D9023CEEEC400AE83E5 /* EditorContentEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentEncoderTests.swift; sourceTree = "<group>"; };
191194
1B1C3726244BE0D60028E1ED /* EditorViewContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorViewContextTests.swift; sourceTree = "<group>"; };
195+
1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorLineNumberProvider.swift; sourceTree = "<group>"; };
196+
1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineNumberFormatting.swift; sourceTree = "<group>"; };
197+
1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLineNumberProvider.swift; sourceTree = "<group>"; };
192198
1B238D6D2456A40200BF49D5 /* NullRichTextEditorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullRichTextEditorContext.swift; sourceTree = "<group>"; };
193199
1B2BC0D723CF17E300407DEE /* EditorContentTransformerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentTransformerTests.swift; sourceTree = "<group>"; };
194200
1B2BC0DB23CF18C100407DEE /* EditorContentDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorContentDecoding.swift; sourceTree = "<group>"; };
@@ -448,6 +454,7 @@
448454
1B45CDCF23C007AF001EB196 /* RichTextView.swift */,
449455
1B9B744C2A8B09EF00FF4E92 /* GestureRegognizerDelegateOverride.swift */,
450456
1BD21553246951090000BCE2 /* LayoutManager.swift */,
457+
1B21AD062B74614C00EBC0BF /* LineNumberFormatting.swift */,
451458
1B45CDD123C00856001EB196 /* TextContainer.swift */,
452459
1B7A985823C484BC00C34B14 /* RichTextViewDelegate.swift */,
453460
1B975AFE23CD454700EC410C /* RichTextViewContext.swift */,
@@ -459,6 +466,7 @@
459466
1B30A35F2489CE3E00FA1D48 /* ListFormattingProvider.swift */,
460467
1BFDC80E254A9BFC00BD83BD /* ListParser.swift */,
461468
1B7C18892AAEC078005457D9 /* AsyncTaskScheduler.swift */,
469+
1B21AD042B74604C00EBC0BF /* EditorLineNumberProvider.swift */,
462470
);
463471
path = Core;
464472
sourceTree = "<group>";
@@ -855,6 +863,7 @@
855863
1B8BE91E23C71E8A00353B17 /* MockEditorViewDelegate.swift */,
856864
1BD993C323CACCE100563ACB /* MockAttachment.swift */,
857865
1B6FB1892ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift */,
866+
1B21AD082B74628600EBC0BF /* MockLineNumberProvider.swift */,
858867
);
859868
path = Mocks;
860869
sourceTree = "<group>";
@@ -1016,6 +1025,7 @@
10161025
buildActionMask = 2147483647;
10171026
files = (
10181027
1B238D6E2456A40200BF49D5 /* NullRichTextEditorContext.swift in Sources */,
1028+
1B21AD072B74614C00EBC0BF /* LineNumberFormatting.swift in Sources */,
10191029
1B45CDBE23BF125D001EB196 /* NSAttributedString+ContentTypes.swift in Sources */,
10201030
1B7A985723C4828A00C34B14 /* RichTextEditorContext.swift in Sources */,
10211031
1BC0AA64284DF918004B8862 /* GridConfiguration.swift in Sources */,
@@ -1030,6 +1040,7 @@
10301040
1B4B60CA247FC51E002B63CF /* ListCommand.swift in Sources */,
10311041
1B7C76AB2608A489006618AC /* BoldCommand.swift in Sources */,
10321042
1B7C188C2AB17621005457D9 /* SynchronizedArray.swift in Sources */,
1043+
1B21AD052B74604C00EBC0BF /* EditorLineNumberProvider.swift in Sources */,
10331044
1BBAC3CF23CD5A1B0088A1C8 /* UITextRangeExtensions.swift in Sources */,
10341045
1BFDC80F254A9BFC00BD83BD /* ListParser.swift in Sources */,
10351046
1B7C76AC2608A489006618AC /* ItalicsCommand.swift in Sources */,
@@ -1155,6 +1166,7 @@
11551166
1B30A3622489DC7B00FA1D48 /* MockListFormattingProvider.swift in Sources */,
11561167
1BD185C4284C33B0001F4FBC /* GridViewSnapshotTests.swift in Sources */,
11571168
1B6DE9D923C5940B007F9859 /* EditorCommandSnapshotTests.swift in Sources */,
1169+
1B21AD0A2B7462AD00EBC0BF /* MockLineNumberProvider.swift in Sources */,
11581170
);
11591171
runOnlyForDeploymentPostprocessing = 0;
11601172
};

Proton/Sources/Swift/Base/AutogrowingTextView.swift

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class AutogrowingTextView: UITextView {
4343
heightAnchorConstraint
4444
])
4545
}
46+
//TODO: enable only when line numbering is turned on
47+
contentMode = .redraw
4648
}
4749

4850
required init?(coder: NSCoder) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// EditorLineNumberProvider.swift
3+
// Proton
4+
//
5+
// Created by Rajdeep Kwatra on 8/2/2024.
6+
// Copyright © 2023 Rajdeep Kwatra. All rights reserved.
7+
//
8+
// Licensed under the Apache License, Version 2.0 (the "License");
9+
// you may not use this file except in compliance with the License.
10+
// You may obtain a copy of the License at
11+
//
12+
// http://www.apache.org/licenses/LICENSE-2.0
13+
//
14+
// Unless required by applicable law or agreed to in writing, software
15+
// distributed under the License is distributed on an "AS IS" BASIS,
16+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
// See the License for the specific language governing permissions and
18+
// limitations under the License.
19+
//
20+
21+
import Foundation
22+
import UIKit
23+
24+
/// Describes an object capable of providing numbers to be displayed when `isLineNumbersEnabled` is set to `true` in `EditorView`
25+
public protocol LineNumberProvider: AnyObject {
26+
var lineNumberWrappingMarker: String? { get }
27+
28+
func lineNumberString(for index: Int) -> String?
29+
}

Proton/Sources/Swift/Core/LayoutManager.swift

+75
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ protocol LayoutManagerDelegate: AnyObject {
3030
var textContainerInset: UIEdgeInsets { get }
3131

3232
var listLineFormatting: LineFormatting { get }
33+
34+
var isLineNumbersEnabled: Bool { get }
35+
var lineNumberFormatting: LineNumberFormatting { get }
36+
var lineNumberWrappingMarker: String? { get }
3337

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

3742
class LayoutManager: NSLayoutManager {
@@ -270,11 +275,33 @@ class LayoutManager: NSLayoutManager {
270275
return stringRect
271276
}
272277

278+
private func rectForLineNumbers(markerSize: CGSize, rect: CGRect, width: CGFloat) -> CGRect {
279+
let topInset = layoutManagerDelegate?.textContainerInset.top ?? 0
280+
let spacerRect = CGRect(origin: CGPoint(x: 0, y: topInset), size: CGSize(width: width, height: rect.height))
281+
282+
let scaleFactor = markerSize.height / spacerRect.height
283+
var markerSizeToUse = markerSize
284+
// Resize maintaining aspect ratio if bullet height is more than available line height
285+
if scaleFactor > 1 {
286+
markerSizeToUse = CGSize(width: markerSize.width/scaleFactor, height: markerSize.height/scaleFactor)
287+
}
288+
289+
let trailingPadding: CGFloat = 2
290+
let yPos = topInset + rect.minY
291+
let stringRect = CGRect(origin: CGPoint(x: spacerRect.maxX - markerSizeToUse.width - trailingPadding, y: yPos), size: markerSizeToUse)
292+
293+
// debugRect(rect: spacerRect, color: .blue)
294+
// debugRect(rect: stringRect, color: .red)
295+
296+
return stringRect
297+
}
298+
273299
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
274300
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
275301
guard let textStorage = textStorage,
276302
let currentCGContext = UIGraphicsGetCurrentContext()
277303
else { return }
304+
currentCGContext.saveGState()
278305

279306
let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
280307
textStorage.enumerateAttribute(.backgroundStyle, in: characterRange) { attr, bgStyleRange, _ in
@@ -321,6 +348,54 @@ class LayoutManager: NSLayoutManager {
321348
drawBackground(backgroundStyle: backgroundStyle, rects: rects, currentCGContext: currentCGContext)
322349
}
323350
}
351+
drawLineNumbers(textStorage: textStorage, currentCGContext: currentCGContext)
352+
currentCGContext.restoreGState()
353+
}
354+
355+
private func drawLineNumbers(textStorage: NSTextStorage, currentCGContext: CGContext) {
356+
var lineNumber = 1
357+
guard layoutManagerDelegate?.isLineNumbersEnabled == true,
358+
let lineNumberFormatting = layoutManagerDelegate?.lineNumberFormatting else { return }
359+
360+
let lineNumberWrappingMarker = layoutManagerDelegate?.lineNumberWrappingMarker
361+
enumerateLineFragments(forGlyphRange: textStorage.fullRange) { [weak self] rect, usedRect, _, range, _ in
362+
guard let self else { return }
363+
let paraRange = self.textStorage?.mutableString.paragraphRange(for: range).firstCharacterRange
364+
let lineNumberToDisplay = layoutManagerDelegate?.lineNumberString(for: lineNumber) ?? "\(lineNumber)"
365+
366+
if range.location == paraRange?.location {
367+
self.drawLineNumber(lineNumber: lineNumberToDisplay, rect: rect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
368+
lineNumber += 1
369+
} else if let lineNumberWrappingMarker {
370+
self.drawLineNumber(lineNumber: lineNumberWrappingMarker, rect: rect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
371+
}
372+
}
373+
374+
// Draw line number for additional new line with \n, if exists
375+
drawLineNumber(lineNumber: "\(lineNumber)", rect: extraLineFragmentRect.integral, lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
376+
}
377+
378+
func drawLineNumber(lineNumber: String, rect: CGRect, lineNumberFormatting: LineNumberFormatting, currentCGContext: CGContext) {
379+
let gutterWidth = lineNumberFormatting.gutter.width
380+
let attributes = lineNumberAttributes(lineNumberFormatting: lineNumberFormatting)
381+
let text = NSAttributedString(string: "\(lineNumber)", attributes: attributes)
382+
let markerSize = text.boundingRect(with: .zero, options: [], context: nil).integral.size
383+
var markerRect = self.rectForLineNumbers(markerSize: markerSize, rect: rect, width: gutterWidth)
384+
let listMarkerImage = self.generateBitmap(string: text, rect: markerRect)
385+
listMarkerImage.draw(at: markerRect.origin)
386+
}
387+
388+
private func lineNumberAttributes(lineNumberFormatting: LineNumberFormatting) -> [NSAttributedString.Key: Any] {
389+
let font = lineNumberFormatting.font
390+
let textColor = lineNumberFormatting.textColor
391+
let paraStyle = NSMutableParagraphStyle()
392+
paraStyle.alignment = .right
393+
394+
return [
395+
NSAttributedString.Key.font: font,
396+
NSAttributedString.Key.foregroundColor: textColor,
397+
NSAttributedString.Key.paragraphStyle: paraStyle
398+
]
324399
}
325400

326401
private func drawBackground(backgroundStyle: BackgroundStyle, rects: [CGRect], currentCGContext: CGContext) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// LineNumberFormatting.swift
3+
// Proton
4+
//
5+
// Created by Rajdeep Kwatra on 8/2/2023.
6+
// Copyright © 2023 Rajdeep Kwatra. All rights reserved.
7+
//
8+
// Licensed under the Apache License, Version 2.0 (the "License");
9+
// you may not use this file except in compliance with the License.
10+
// You may obtain a copy of the License at
11+
//
12+
// http://www.apache.org/licenses/LICENSE-2.0
13+
//
14+
// Unless required by applicable law or agreed to in writing, software
15+
// distributed under the License is distributed on an "AS IS" BASIS,
16+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
// See the License for the specific language governing permissions and
18+
// limitations under the License.
19+
//
20+
21+
import Foundation
22+
import UIKit
23+
24+
public struct Gutter {
25+
public let lineWidth: CGFloat
26+
public let lineColor: UIColor?
27+
public let width: CGFloat
28+
public let backgroundColor: UIColor
29+
30+
init(width: CGFloat, backgroundColor: UIColor, lineColor: UIColor? = nil, lineWidth: CGFloat = 1) {
31+
self.width = width
32+
self.lineColor = lineColor
33+
self.lineWidth = (lineColor != nil) ? lineWidth : 0
34+
self.backgroundColor = backgroundColor
35+
}
36+
}
37+
38+
public struct LineNumberFormatting {
39+
40+
public static let `default` = LineNumberFormatting(
41+
textColor: .darkGray, font: .monospacedDigitSystemFont(ofSize: 17, weight: .light),
42+
gutter: Gutter(width: 30, backgroundColor: .lightGray))
43+
44+
public let textColor: UIColor
45+
public let font: UIFont
46+
public let gutter: Gutter
47+
48+
init(textColor: UIColor, font: UIFont, gutter: Gutter) {
49+
self.textColor = textColor
50+
self.font = font
51+
self.gutter = gutter
52+
}
53+
}

Proton/Sources/Swift/Core/RichTextView.swift

+95
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class RichTextView: AutogrowingTextView {
3131
weak var richTextViewDelegate: RichTextViewDelegate?
3232
weak var richTextViewListDelegate: RichTextViewListDelegate?
3333
weak var richTextScrollViewDelegate: UIScrollViewDelegate?
34+
weak var lineNumberProvider: LineNumberProvider?
3435

3536
private var delegateOverrides = [GestureRecognizerDelegateOverride]()
3637

@@ -85,6 +86,87 @@ class RichTextView: AutogrowingTextView {
8586
}
8687
}
8788

89+
var lineNumberFormatting = LineNumberFormatting.default {
90+
didSet {
91+
let gutterOffset = lineNumberFormatting.gutter.width + lineNumberFormatting.gutter.lineWidth
92+
let adjustedLeftInset = isLineNumbersEnabled ? (gutterOffset + textContainerInset.left - oldValue.gutter.width): nil
93+
94+
textContainerInset = UIEdgeInsets(
95+
top: textContainerInset.top,
96+
left: adjustedLeftInset ?? textContainerInset.left,
97+
bottom: textContainerInset.bottom,
98+
right: textContainerInset.right
99+
)
100+
setNeedsDisplay()
101+
}
102+
}
103+
104+
var isLineNumbersEnabled = false {
105+
didSet {
106+
let gutterOffset = lineNumberFormatting.gutter.width + lineNumberFormatting.gutter.lineWidth
107+
108+
let adjustedLeftInset: CGFloat
109+
switch (oldValue, isLineNumbersEnabled) {
110+
case (false, true):
111+
adjustedLeftInset = gutterOffset + textContainerInset.left
112+
case (true, false):
113+
adjustedLeftInset = textContainerInset.left - gutterOffset
114+
default:
115+
adjustedLeftInset = textContainerInset.left
116+
}
117+
118+
textContainerInset = UIEdgeInsets(
119+
top: textContainerInset.top,
120+
left: adjustedLeftInset,
121+
bottom: textContainerInset.bottom,
122+
right: textContainerInset.right
123+
)
124+
setNeedsDisplay()
125+
}
126+
}
127+
128+
override func draw(_ rect: CGRect) {
129+
guard isLineNumbersEnabled,
130+
let currentCGContext = UIGraphicsGetCurrentContext() else {
131+
super.draw(rect)
132+
return
133+
}
134+
135+
let height = max(contentSize.height, bounds.height)
136+
let rect = CGRect(x: 0, y: 0, width: lineNumberFormatting.gutter.width, height: height)
137+
let rectanglePath = UIBezierPath(rect: rect)
138+
139+
currentCGContext.saveGState()
140+
currentCGContext.addPath(rectanglePath.cgPath)
141+
142+
if let lineColor = lineNumberFormatting.gutter.lineColor {
143+
currentCGContext.setStrokeColor(lineColor.cgColor)
144+
currentCGContext.setLineWidth(lineNumberFormatting.gutter.lineWidth)
145+
currentCGContext.drawPath(using: .stroke)
146+
}
147+
148+
currentCGContext.setFillColor(lineNumberFormatting.gutter.backgroundColor.cgColor)
149+
currentCGContext.fill(rect)
150+
151+
// Draw line number if textView is empty
152+
if let layoutManager = layoutManager as? LayoutManager,
153+
attributedText.length == 0 {
154+
let lineNumberToDisplay = lineNumberString(for: 1) ?? "1"
155+
let width = lineNumberFormatting.gutter.width
156+
let height = defaultFont.lineHeight
157+
layoutManager.drawLineNumber(lineNumber: lineNumberToDisplay, rect: CGRect(origin: .zero, size: CGSize(width: width, height: height)), lineNumberFormatting: lineNumberFormatting, currentCGContext: currentCGContext)
158+
}
159+
160+
currentCGContext.restoreGState()
161+
162+
super.draw(rect)
163+
}
164+
165+
func drawDefaultLineNumberIfRequired() {
166+
guard isLineNumbersEnabled else { return }
167+
draw(CGRect(origin: .zero, size: contentSize))
168+
}
169+
88170
override var selectedTextRange: UITextRange? {
89171
didSet{
90172
let old = oldValue?.toNSRange(in: self)
@@ -503,6 +585,10 @@ class RichTextView: AutogrowingTextView {
503585
return
504586
}
505587
setupPlaceholder()
588+
if isLineNumbersEnabled {
589+
//TODO: else use default
590+
contentMode = .redraw
591+
}
506592
}
507593

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

726812
func textStorage(_ textStorage: PRTextStorage, edited actions: NSTextStorage.EditActions, in editedRange: NSRange, changeInLength delta: Int) {
727813
updatePlaceholderVisibility()
814+
drawDefaultLineNumberIfRequired()
728815
}
729816
}
730817

731818
extension RichTextView: LayoutManagerDelegate {
819+
var lineNumberWrappingMarker: String? {
820+
lineNumberProvider?.lineNumberWrappingMarker
821+
}
822+
732823
var listLineFormatting: LineFormatting {
733824
return richTextViewListDelegate?.listLineFormatting ?? RichTextView.defaultListLineFormatting
734825
}
@@ -737,6 +828,10 @@ extension RichTextView: LayoutManagerDelegate {
737828
return defaultTextFormattingProvider?.paragraphStyle
738829
}
739830

831+
func lineNumberString(for index: Int) -> String? {
832+
lineNumberProvider?.lineNumberString(for: index)
833+
}
834+
740835
func listMarkerForItem(at index: Int, level: Int, previousLevel: Int, attributeValue: Any?) -> ListLineMarker {
741836
let font = UIFont.preferredFont(forTextStyle: .body)
742837
let defaultValue = NSAttributedString(string: "*", attributes: [.font: font])

0 commit comments

Comments
 (0)