From 4d7a00b32df8ede1ee358e40864d3d9bdf795fa2 Mon Sep 17 00:00:00 2001 From: Evan Maloney Date: Wed, 1 Mar 2017 23:05:40 -0500 Subject: [PATCH] initial LiveLogInspector view controller & view --- CleanroomLogger.xcodeproj/project.pbxproj | 18 + Sources/BufferedLogRecorder.swift | 87 ++- Sources/CallbackRegistry.swift | 85 +++ Sources/LiveLogInspectorConfiguration.swift | 40 ++ Sources/LiveLogInspectorView.swift | 638 +++++++++++++++++++ Sources/LiveLogInspectorViewController.swift | 79 +++ 6 files changed, 936 insertions(+), 11 deletions(-) create mode 100644 Sources/CallbackRegistry.swift create mode 100644 Sources/LiveLogInspectorConfiguration.swift create mode 100644 Sources/LiveLogInspectorView.swift create mode 100644 Sources/LiveLogInspectorViewController.swift diff --git a/CleanroomLogger.xcodeproj/project.pbxproj b/CleanroomLogger.xcodeproj/project.pbxproj index 7c3bbfad..ba46df57 100644 --- a/CleanroomLogger.xcodeproj/project.pbxproj +++ b/CleanroomLogger.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ 3B2AFD551E562EEB00C115F7 /* BufferedLogRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD541E562EEB00C115F7 /* BufferedLogRecorder.swift */; }; 3B2AFD581E579E9500C115F7 /* KeyedMessageBufferExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD561E579E7100C115F7 /* KeyedMessageBufferExtension.swift */; }; 3B2AFD5A1E579EEC00C115F7 /* LoggerTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD591E579EEC00C115F7 /* LoggerTestCase.swift */; }; + 3B2AFD5F1E57A81400C115F7 /* LiveLogInspectorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD5E1E57A81400C115F7 /* LiveLogInspectorConfiguration.swift */; }; + 3B2AFD611E59254400C115F7 /* LiveLogInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD601E59254400C115F7 /* LiveLogInspectorView.swift */; }; + 3B2AFD631E59256D00C115F7 /* LiveLogInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD621E59256D00C115F7 /* LiveLogInspectorViewController.swift */; }; + 3B2AFD651E5CEA8E00C115F7 /* CallbackRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2AFD641E5CEA8E00C115F7 /* CallbackRegistry.swift */; }; 3B475D271DB47B8A0047D397 /* BasicLogConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B475D001DB47B8A0047D397 /* BasicLogConfiguration.swift */; }; 3B475D281DB47B8A0047D397 /* CallingThreadLogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B475D011DB47B8A0047D397 /* CallingThreadLogFormatter.swift */; }; 3B475D291DB47B8A0047D397 /* CallSiteLogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B475D021DB47B8A0047D397 /* CallSiteLogFormatter.swift */; }; @@ -80,6 +84,10 @@ 3B2AFD541E562EEB00C115F7 /* BufferedLogRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BufferedLogRecorder.swift; sourceTree = ""; }; 3B2AFD561E579E7100C115F7 /* KeyedMessageBufferExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = KeyedMessageBufferExtension.swift; path = CleanroomLoggerTests/KeyedMessageBufferExtension.swift; sourceTree = ""; }; 3B2AFD591E579EEC00C115F7 /* LoggerTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoggerTestCase.swift; path = CleanroomLoggerTests/LoggerTestCase.swift; sourceTree = ""; }; + 3B2AFD5E1E57A81400C115F7 /* LiveLogInspectorConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLogInspectorConfiguration.swift; sourceTree = ""; }; + 3B2AFD601E59254400C115F7 /* LiveLogInspectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLogInspectorView.swift; sourceTree = ""; }; + 3B2AFD621E59256D00C115F7 /* LiveLogInspectorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLogInspectorViewController.swift; sourceTree = ""; }; + 3B2AFD641E5CEA8E00C115F7 /* CallbackRegistry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallbackRegistry.swift; sourceTree = ""; }; 3B475D001DB47B8A0047D397 /* BasicLogConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasicLogConfiguration.swift; sourceTree = ""; }; 3B475D011DB47B8A0047D397 /* CallingThreadLogFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallingThreadLogFormatter.swift; sourceTree = ""; }; 3B475D021DB47B8A0047D397 /* CallSiteLogFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallSiteLogFormatter.swift; sourceTree = ""; }; @@ -152,6 +160,7 @@ children = ( 3B475D001DB47B8A0047D397 /* BasicLogConfiguration.swift */, 3B2AFD541E562EEB00C115F7 /* BufferedLogRecorder.swift */, + 3B2AFD641E5CEA8E00C115F7 /* CallbackRegistry.swift */, 3B475D011DB47B8A0047D397 /* CallingThreadLogFormatter.swift */, 3B475D021DB47B8A0047D397 /* CallSiteLogFormatter.swift */, 3B475D061DB47B8A0047D397 /* ConcatenatingLogFormatter.swift */, @@ -160,6 +169,9 @@ 3B475D091DB47B8A0047D397 /* FieldBasedLogFormatter.swift */, 3B475D0A1DB47B8A0047D397 /* FileLogRecorder.swift */, 3B475D0B1DB47B8A0047D397 /* LiteralLogFormatter.swift */, + 3B2AFD5E1E57A81400C115F7 /* LiveLogInspectorConfiguration.swift */, + 3B2AFD601E59254400C115F7 /* LiveLogInspectorView.swift */, + 3B2AFD621E59256D00C115F7 /* LiveLogInspectorViewController.swift */, 3B475D0C1DB47B8A0047D397 /* Log.swift */, 3B475D0D1DB47B8A0047D397 /* LogChannel.swift */, 3B475D0E1DB47B8A0047D397 /* LogConfiguration.swift */, @@ -375,6 +387,7 @@ 3B4EE3561E1BED6D002A92BC /* XcodeTraceLogFormatter.swift in Sources */, 3B17126B1E20A7AB00682E89 /* PayloadValueLogFormatter.swift in Sources */, 3B475D4B1DB47B8A0047D397 /* XcodeLogConfiguration.swift in Sources */, + 3B2AFD631E59256D00C115F7 /* LiveLogInspectorViewController.swift in Sources */, 3B1712671E20A50600682E89 /* PayloadTraceLogFormatter.swift in Sources */, 3B4EE3521E1AEF49002A92BC /* OSLogTypeTranslator.swift in Sources */, 3B475D431DB47B8A0047D397 /* RotatingLogFileRecorder.swift in Sources */, @@ -399,7 +412,10 @@ 3B4EE3601E1C3E84002A92BC /* ProcessIDLogFormatter.swift in Sources */, 3B475D321DB47B8A0047D397 /* LiteralLogFormatter.swift in Sources */, 3B475D3B1DB47B8A0047D397 /* LogRecorderBase.swift in Sources */, + 3B2AFD5F1E57A81400C115F7 /* LiveLogInspectorConfiguration.swift in Sources */, + 3B2AFD651E5CEA8E00C115F7 /* CallbackRegistry.swift in Sources */, 3B2811491E1D65E100878EC2 /* StandardErrorLogRecorder.swift in Sources */, + 3B2AFD611E59254400C115F7 /* LiveLogInspectorView.swift in Sources */, 3B1712691E20A66000682E89 /* PayloadMessageLogFormatter.swift in Sources */, 3B475D461DB47B8A0047D397 /* StandardLogFormatter.swift in Sources */, 3B475D3D1DB47B8A0047D397 /* ParsableLogFormatter.swift in Sources */, @@ -535,6 +551,7 @@ DYLIB_CURRENT_VERSION = 27; DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; SKIP_INSTALL = YES; SWIFT_VERSION = 3.0; }; @@ -549,6 +566,7 @@ DYLIB_CURRENT_VERSION = 27; DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; SKIP_INSTALL = YES; SWIFT_VERSION = 3.0; }; diff --git a/Sources/BufferedLogRecorder.swift b/Sources/BufferedLogRecorder.swift index 2eb3640e..980cc5c6 100644 --- a/Sources/BufferedLogRecorder.swift +++ b/Sources/BufferedLogRecorder.swift @@ -28,9 +28,15 @@ import Dispatch open class BufferedLogRecorder: LogRecorderBase { /** The maximum number if items that will be stored in the receiver's - buffer */ + buffer. */ open let bufferLimit: Int + /** If `true`, the items in the buffer are stored in reverse chronological + order: the first item in the buffer array will be the newest, while the + last item will be the oldest. Otherwise, the array will be ordered from + oldest to newest. */ + open let reverseChronological: Bool + /** The function used to create a `BufferItem` given a `LogEntry` and a formatted message string. */ open let createBufferItem: (LogEntry, String) -> BufferItem @@ -39,6 +45,21 @@ open class BufferedLogRecorder: LogRecorderBase `LogEntry` values recorded by the receiver. */ open private(set) var buffer: [BufferItem] + private var didRecordItemCallbacks: CallbackRegistry<(_ recorder: BufferedLogRecorder, _ item: BufferItem, _ didTruncateBuffer: Bool) -> Void> + private var didClearBufferCallbacks: CallbackRegistry<(_ recorder: BufferedLogRecorder) -> Void> + + /** A callback function that gets executed on the main thread once for each + call to `record()`. The caller and the recorded `BufferItem` are passed as + parameters, along with a flag indicating whether the buffer was truncated + due to hitting the `bufferLimit`. When this function is called, the item + will have already been added to the buffer array. */ +// open var didRecordBufferItem: (_ recorder: BufferedLogRecorder, _ item: BufferItem, _ didTruncateBuffer: Bool) -> Void = { _, _, _ in } + + /** A callback function that gets executed on the main thread whenever the + buffer is cleared. The caller is passed as the parameter. When this + function is called, the buffer will have already been cleared. */ +// open var didClearBuffer: (_ recorder: BufferedLogRecorder) -> Void = { _ in } + /** Initializes a new `BufferedLogRecorder`. @@ -56,22 +77,48 @@ open class BufferedLogRecorder: LogRecorderBase consumption will grow endlessly unless you manually clear the buffer periodically. + - parameter reverseChronological: If `true`, the items in the buffer will + be stored in reverse chronological order: the first item in the buffer + array will be the newest, while the last item will be the oldest. + Otherwise, the array will be ordered from oldest to newest. + - parameter queue: The `DispatchQueue` to use for the recorder. If `nil`, a new queue will be created. - + - parameter createBufferItem: The function used to create `BufferItem` instances for each `LogEntry` and formatted message string passed to the receiver's `record`()` function. */ - public init(formatters: [LogFormatter], bufferLimit: Int = 10_000, queue: DispatchQueue? = nil, createBufferItem: @escaping (LogEntry, String) -> BufferItem) + public init(formatters: [LogFormatter], bufferLimit: Int = 10_000, reverseChronological: Bool = false, queue: DispatchQueue? = nil, createBufferItem: @escaping (LogEntry, String) -> BufferItem) { self.buffer = [] self.bufferLimit = bufferLimit + self.reverseChronological = reverseChronological self.createBufferItem = createBufferItem + self.didRecordItemCallbacks = CallbackRegistry<(_ recorder: BufferedLogRecorder, _ item: BufferItem, _ didTruncateBuffer: Bool) -> Void>() + self.didClearBufferCallbacks = CallbackRegistry<(_ recorder: BufferedLogRecorder) -> Void>() + super.init(formatters: formatters, queue: queue) } - + + open func addCallback(didRecordBufferItem: @escaping (_ recorder: BufferedLogRecorder, _ item: BufferItem, _ didTruncateBuffer: Bool) -> Void) + -> CallbackHandle + { + return didRecordItemCallbacks.addCallback(didRecordBufferItem) + } + + open func addCallback(didClearBuffer: @escaping (_ recorder: BufferedLogRecorder) -> Void) + -> CallbackHandle + { + return didClearBufferCallbacks.addCallback(didClearBuffer) + } + + open func removeCallback(handle: CallbackHandle) + { + handle.stopCallbacks() + } + /** Called by the `LogReceptacle` to record the formatted log message. @@ -92,10 +139,24 @@ open class BufferedLogRecorder: LogRecorderBase { let item = createBufferItem(entry, message) - buffer.append(item) + var didTruncate = false + if bufferLimit > 0 && buffer.count + 1 > bufferLimit { + if reverseChronological { + buffer.removeLast() + } else { + buffer.removeFirst() + } + didTruncate = true + } + + if reverseChronological { + buffer.insert(item, at: 0) + } else { + buffer.append(item) + } - if bufferLimit > 0 && buffer.count > bufferLimit { - buffer.remove(at: 0) + for callback in didRecordItemCallbacks.callbacks() { + callback(self, item, didTruncate) } } @@ -107,10 +168,14 @@ open class BufferedLogRecorder: LogRecorderBase */ public func clear() { - // ensures consistent access to buffer, preventing race conditions -// queue.sync { - self.buffer = [] -// } + // ensures consistent access to buffer + queue.sync { + self.buffer.removeAll(keepingCapacity: true) + + for callback in didClearBufferCallbacks.callbacks() { + callback(self) + } + } } } diff --git a/Sources/CallbackRegistry.swift b/Sources/CallbackRegistry.swift new file mode 100644 index 00000000..591eb6fe --- /dev/null +++ b/Sources/CallbackRegistry.swift @@ -0,0 +1,85 @@ +// +// CallbackRegistry.swift +// CleanroomLogger +// +// Created by Evan Maloney on 2/21/17. +// Copyright © 2017 Gilt Groupe. All rights reserved. +// + +import Foundation + +/** + Represents a callback function that has been registered for a given operation. + + The callback function may be called as long as the associated `CallbackHandle` + instance has at least one strong reference to it. Once a `CallbackHandle` + has been deallocated or its `stopCallbacks()` function is called, the + associated callback function will no longer be invoked. + */ +public class CallbackHandle +{ + internal var objectID: ObjectIdentifier { + return _objectID + } + private var _objectID: ObjectIdentifier! + + private weak var removableFrom: CallbackRemovable? + + fileprivate init(removableFrom: CallbackRemovable) + { + self.removableFrom = removableFrom + _objectID = ObjectIdentifier(self) + } + + deinit { + stopCallbacks() + } + + /** + Prevent further invocations of the callback function represented by the + receiver. + */ + public func stopCallbacks() + { + removableFrom?.removeCallback(handle: self) + removableFrom = nil + } +} + +private protocol CallbackRemovable: class +{ + func removeCallback(handle: CallbackHandle) +} + +internal class CallbackRegistry: CallbackRemovable +{ + private let lock = NSLock() + private var objectIdsToCallbacks = [ObjectIdentifier: CallbackSignature]() + + func addCallback(_ callback: CallbackSignature) + -> CallbackHandle + { + let handle = CallbackHandle(removableFrom: self) + lock.lock() + objectIdsToCallbacks[handle.objectID] = callback + lock.unlock() + return handle + } + + func removeCallback(handle: CallbackHandle) + { + let id = handle.objectID + lock.lock() + objectIdsToCallbacks.removeValue(forKey: id) + lock.unlock() + } + + func callbacks() + -> [CallbackSignature] + { + lock.lock() + let callbacks = objectIdsToCallbacks.values + lock.unlock() + return [CallbackSignature](callbacks) + } +} diff --git a/Sources/LiveLogInspectorConfiguration.swift b/Sources/LiveLogInspectorConfiguration.swift new file mode 100644 index 00000000..3c54c068 --- /dev/null +++ b/Sources/LiveLogInspectorConfiguration.swift @@ -0,0 +1,40 @@ + // +// LiveLogInspectorConfiguration.swift +// CleanroomLogger +// +// Created by Evan Maloney on 2/17/17. +// Copyright © 2017 Gilt Groupe. All rights reserved. +// + +#if os(iOS) || os(tvOS) + +import Foundation +import UIKit + +open class LiveLogInspectorConfiguration: BasicLogConfiguration +{ + private let bufferingRecorder: BufferedLogEntryMessageRecorder + + open var inspectorViewController: LiveLogInspectorViewController { + let inspector = _inspectorViewController ?? LiveLogInspectorViewController(recorder: bufferingRecorder) + _inspectorViewController = inspector + return inspector + } + private weak var _inspectorViewController: LiveLogInspectorViewController? + + public init(minimumSeverity: LogSeverity = .verbose, filters: [LogFilter] = [], synchronousMode: Bool = false) + { + bufferingRecorder = BufferedLogEntryMessageRecorder(formatters: [PayloadLogFormatter()]) + + super.init(minimumSeverity: minimumSeverity, filters: filters, recorders: [bufferingRecorder], synchronousMode: synchronousMode) + } + + open func createInspectorView() + -> LiveLogInspectorView + { + return LiveLogInspectorView(recorder: bufferingRecorder) + } +} + +#endif + diff --git a/Sources/LiveLogInspectorView.swift b/Sources/LiveLogInspectorView.swift new file mode 100644 index 00000000..23d9ceb3 --- /dev/null +++ b/Sources/LiveLogInspectorView.swift @@ -0,0 +1,638 @@ +// +// LiveLogInspectorView.swift +// CleanroomLogger +// +// Created by Evan Maloney on 2/18/17. +// Copyright © 2017 Gilt Groupe. All rights reserved. +// + +#if os(iOS) || os(tvOS) + +import Foundation +import UIKit + +private let cellID = "LogEntryCell" +private let headerHeight = CGFloat(50) +private let padding = CGFloat(6) +private let closeButtonSize = CGFloat(38) + +/** + The `LiveLogInspectorView` provides a live view of the `LogEntry` messages + recorded by a `BufferedLogEntryMessageRecorder`. + */ +open class LiveLogInspectorView: UIView +{ + /** A function applies styling to the `UILabel` used to display the content + of a log message. This function is called after the `text` of the label + has been set. You may replace this function to customize the appearance + of the label. */ + open var styleMessageLabel: (UILabel) -> Void = { label in + label.font = UIFont.systemFont(ofSize: UIFont.systemFontSize) + } + + /** A function applies styling to the `UILabel` used to display the + `severity` property of a `LogEntry`. This function is called after the + `text` of the label has been set. You may replace this function to + customize the appearance of the label. */ + open var styleSeverityLabel: (UILabel) -> Void = { label in + label.font = UIFont.systemFont(ofSize: UIFont.systemFontSize) + } + + /** A function applies styling to the `UILabel` used to display the + `timestamp` property of a `LogEntry`. This function is called after the + `text` of the label has been set. You may replace this function to + customize the appearance of the label. */ + open var styleTimestampLabel: (UILabel) -> Void = { label in + label.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize) + label.textColor = .darkGray + } + + /** A function called when the close button is tapped in the view. + By default, removes the view from its superview. However, when the view + is in a presented `UIViewController`, this function is replaced with an + implementation that dismisses the view controller. */ + open var closeButtonTriggered: (LiveLogInspectorView) -> Void = { view in + view.removeFromSuperview() + } + + open var isSortedNewestFirst = true { + didSet { + tableView.reloadData() + } + } + + open var minimumSeverity = LogSeverity.verbose { + didSet { + tableView.reloadData() + } + } + + open var isFollowing = true { + didSet { + headerView.updateFollowingButton() + + if isFollowing && !oldValue { + self.follow() + } + } + } + + open var statusBarHeight = CGFloat(0) { + didSet { + guard statusBarHeight != oldValue else { return } + + let newHeaderHeight = headerHeight + statusBarHeight + headerBackgroundHeightConstraint.constant = newHeaderHeight + headerTopConstraint.constant = statusBarHeight + tableView.contentInset = UIEdgeInsets(top: newHeaderHeight, left: 0, bottom: 0, right: 0) + } + } + + fileprivate var userIsInteracting = false + fileprivate var modifiedWhileUserInteracting = false + fileprivate var itemCount = 0 + + fileprivate let calendar: Calendar + fileprivate let timeFormatter: DateFormatter + fileprivate let dateFormatter: DateFormatter + fileprivate let recorder: BufferedLogEntryMessageRecorder + + fileprivate var reverseNativeBufferOrder: Bool { + return recorder.reverseChronological != isSortedNewestFirst + } + + private let blurView: UIVisualEffectView + private let vibrancyView: UIVisualEffectView + private let tableView: UITableView + private var headerView: LogInspectorHeaderView! + private let tableFeeder: LiveLogTableFeeder + private var headerBackgroundHeightConstraint: NSLayoutConstraint! + private var headerTopConstraint: NSLayoutConstraint! + private var recordItemCallbackHandle: CallbackHandle? + private var clearBufferCallbackHandle: CallbackHandle? + + public init(recorder: BufferedLogEntryMessageRecorder) + { + self.recorder = recorder + tableView = UITableView(frame: .zero, style: .plain) + tableFeeder = LiveLogTableFeeder() + calendar = Calendar(identifier: .gregorian) + + timeFormatter = DateFormatter() + timeFormatter.setLocalizedDateFormatFromTemplate("jmsSSS") + + dateFormatter = DateFormatter() + dateFormatter.doesRelativeDateFormatting = true + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .none + + let blur = UIBlurEffect(style: .extraLight) + blurView = UIVisualEffectView(effect: blur) + vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blur)) + + super.init(frame: .zero) + + tableFeeder.owner = self + tableView.delegate = tableFeeder + tableView.dataSource = tableFeeder + + tableView.rowHeight = UITableViewAutomaticDimension + tableView.estimatedRowHeight = 60 + tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) + tableView.register(LogEntryCell.self, forCellReuseIdentifier: cellID) + + headerView = LogInspectorHeaderView(owner: self) + let headerBackgroundView = UIVisualEffectView(effect: UIBlurEffect()) + + blurView.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + headerBackgroundView.translatesAutoresizingMaskIntoConstraints = false + headerView.translatesAutoresizingMaskIntoConstraints = false + tableView.translatesAutoresizingMaskIntoConstraints = false + + tableView.backgroundView = nil + tableView.backgroundColor = .clear + + blurView.contentView.addSubview(vibrancyView) + addSubview(blurView) + addSubview(tableView) + addSubview(headerBackgroundView) + addSubview(headerView) + + headerTopConstraint = headerView.topAnchor.constraint(equalTo: topAnchor) + headerTopConstraint.isActive = true + headerView.heightAnchor.constraint(equalToConstant: headerHeight).isActive = true + headerView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + + headerBackgroundView.topAnchor.constraint(equalTo: topAnchor).isActive = true + headerBackgroundView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true + headerBackgroundView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true + headerBackgroundHeightConstraint = headerBackgroundView.heightAnchor.constraint(equalToConstant: headerHeight) + headerBackgroundHeightConstraint.isActive = true + + blurView.topAnchor.constraint(equalTo: tableView.topAnchor).isActive = true + blurView.bottomAnchor.constraint(equalTo: tableView.bottomAnchor).isActive = true + blurView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor).isActive = true + blurView.trailingAnchor.constraint(equalTo: tableView.trailingAnchor).isActive = true + + vibrancyView.topAnchor.constraint(equalTo: blurView.topAnchor).isActive = true + vibrancyView.bottomAnchor.constraint(equalTo: blurView.bottomAnchor).isActive = true + vibrancyView.leadingAnchor.constraint(equalTo: blurView.leadingAnchor).isActive = true + vibrancyView.trailingAnchor.constraint(equalTo: blurView.trailingAnchor).isActive = true + + tableView.topAnchor.constraint(equalTo: topAnchor).isActive = true + tableView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + tableView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + tableView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + } + + public required init?(coder: NSCoder) { fatalError() } + + open override func didMoveToWindow() + { + if self.window != nil { + clearBufferCallbackHandle = recorder.addCallback(didClearBuffer: { _ in + DispatchQueue.main.async { + self.tableView.reloadData() + } + }) + + recordItemCallbackHandle = recorder.addCallback(didRecordBufferItem: { _, item, didTruncate in + guard !self.userIsInteracting else { + self.modifiedWhileUserInteracting = true + return + } + + guard item.0.severity >= self.minimumSeverity else { + return + } + + let reverseOrder = self.reverseNativeBufferOrder + DispatchQueue.main.sync { [table = self.tableView, recorder = self.recorder] in + let reverse = recorder.reverseChronological + let lastItem = self.itemCount + let newestRow = reverse ? 0 : lastItem + let oldestRow = reverse ? lastItem : 0 + let insertRow = reverseOrder ? oldestRow : newestRow + let deleteRow = reverseOrder ? newestRow : oldestRow + + table.beginUpdates() + + if didTruncate { + precondition(recorder.bufferLimit > 0) + table.deleteRows(at: [IndexPath(row: deleteRow, section: 0)], with: .fade) + self.itemCount -= 1 + } + + let insertPath = IndexPath(row: insertRow, section: 0) + let insertAtTop = (insertRow == 0) + + let shouldScroll = self.shouldScroll(to: insertRow) + + table.insertRows(at: [insertPath], with: insertAtTop ? .top : .automatic) + self.itemCount += 1 + + table.endUpdates() + + if shouldScroll { + table.scrollToRow(at: insertPath, at: insertAtTop ? .top : .middle, animated: true) + } + } + }) + } + else { + // dropping the references to the handles will + // cause the callbacks added above to be de-registered + clearBufferCallbackHandle = nil + recordItemCallbackHandle = nil + } + } + + open func toggleSortOrder() + { + isSortedNewestFirst = !isSortedNewestFirst + } + + open func toggleIsFollowing() + { + isFollowing = !isFollowing + } + + fileprivate func refresh() + { + tableView.reloadData() + } + + private func shouldScroll(to row: Int) + -> Bool + { + var shouldScroll = false + if isFollowing, let visiblePaths = tableView.indexPathsForVisibleRows { + if row == 0 { + if let firstPath = visiblePaths.first { + shouldScroll = (row <= firstPath.row) + } + } + else { + if let lastPath = visiblePaths.last { + shouldScroll = (row > lastPath.row) + } + } + } + return shouldScroll + } + + fileprivate func follow() + { + let reverse = recorder.reverseChronological + let lastBufferItem = itemCount - 1 + let newestRow = reverse ? 0 : lastBufferItem + let oldestRow = reverse ? lastBufferItem : 0 + let scrollToRow = reverseNativeBufferOrder ? oldestRow : newestRow + + if shouldScroll(to: scrollToRow) { + let scrollToTop = (scrollToRow == 0) + tableView.scrollToRow(at: IndexPath(row: scrollToRow, section: 0), at: scrollToTop ? .top : .middle, animated: true) + } + } +} + +private class LiveLogTableFeeder: NSObject, UITableViewDataSource, UITableViewDelegate +{ + // the LiveLogTableFeeder will never outlive the LiveLogInspectorView that + // owns it; therefore, the implicitly-unwrapped optional here is safe + weak var owner: LiveLogInspectorView! + + fileprivate var buffer: [(LogEntry, String)] { + var buffer = owner.recorder.buffer + if owner.minimumSeverity != .verbose { + buffer = buffer.filter{ $0.0.severity >= self.owner.minimumSeverity } + } + return buffer + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) + -> Int + { + guard section == 0 else { return 0 } + + owner.itemCount = buffer.count + return owner.itemCount + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) + -> UITableViewCell + { + precondition(indexPath.section == 0) + + let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as! LogEntryCell + + let index = owner.reverseNativeBufferOrder + ? owner.itemCount - indexPath.row - 1 + : indexPath.row + + let (logEntry, message) = buffer[index] + cell.set(owner: owner, logEntry: logEntry, message: message) + + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) + { + (cell as! LogEntryCell).refreshTimestampIfNeeded() + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) + { + (cell as! LogEntryCell).setTimestampNeedsRefresh() + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) + { + owner.isFollowing = false + owner.userIsInteracting = true + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) + { + owner.userIsInteracting = false + + if owner.modifiedWhileUserInteracting { + owner.refresh() + owner.modifiedWhileUserInteracting = false + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) + { + guard !owner.isFollowing else { return } + + // see if we should automatically enable follow mode + let topPoint = -scrollView.contentInset.top + let bottomPoint = scrollView.contentSize.height - scrollView.bounds.size.height + + if owner.isSortedNewestFirst { + if (scrollView.contentOffset.y - 10) < topPoint { + owner.isFollowing = true + } + } else { + if (scrollView.contentOffset.y + 10) > bottomPoint { + owner.isFollowing = true + } + } + } +} + +private class LogInspectorHeaderView: UIView +{ + // the header view can't outlive the LiveLogInspectorView that + // contains it; the implicitly-unwrapped optional here is safe + private weak var owner: LiveLogInspectorView! + private let closeButton: UIButton + private let sortButton: UIButton + private let filterButton: UIButton + private let followButton: UIButton + + init(owner: LiveLogInspectorView) + { + self.owner = owner + + self.closeButton = UIButton(type: .custom) + self.sortButton = UIButton(type: .custom) + self.filterButton = UIButton(type: .custom) + self.followButton = UIButton(type: .custom) + + super.init(frame: .zero) + + let buttonFontSize = UIFont.smallSystemFontSize + + closeButton.backgroundColor = UIColor(white: 0.0, alpha: 0.65) + closeButton.setTitle("✖︎", for: .normal) + closeButton.setTitleColor(.white, for: .normal) + closeButton.layer.cornerRadius = (closeButtonSize / 2) + closeButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24) + closeButton.addTarget(self, action: #selector(closeButtonTriggered), for: .primaryActionTriggered) + + sortButton.contentHorizontalAlignment = .left + sortButton.setTitleColor(.black, for: .normal) + sortButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: buttonFontSize) + sortButton.addTarget(self, action: #selector(sortButtonTriggered), for: .primaryActionTriggered) + + filterButton.contentHorizontalAlignment = .left + filterButton.setTitleColor(.black, for: .normal) + filterButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: buttonFontSize) + filterButton.addTarget(self, action: #selector(filterButtonTriggered), for: .primaryActionTriggered) + + followButton.contentHorizontalAlignment = .left + followButton.setTitleColor(.black, for: .normal) + followButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: buttonFontSize) + followButton.addTarget(self, action: #selector(followButtonTriggered), for: .primaryActionTriggered) + followButton.setTitle("⚪️ No follow", for: .normal) + followButton.setTitle("🔘 Following", for: .selected) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + + let stackView = UIStackView(arrangedSubviews: [sortButton, filterButton, followButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = padding + stackView.distribution = .fillEqually + + addSubview(closeButton) + addSubview(stackView) + + closeButton.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + closeButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding).isActive = true + closeButton.widthAnchor.constraint(equalToConstant: closeButtonSize).isActive = true + closeButton.heightAnchor.constraint(equalToConstant: closeButtonSize).isActive = true + + stackView.topAnchor.constraint(equalTo: topAnchor, constant: padding).isActive = true + stackView.leadingAnchor.constraint(equalTo: closeButton.trailingAnchor, constant: padding * 2).isActive = true + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding).isActive = true + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding).isActive = true + } + + required init?(coder: NSCoder) { fatalError() } + + override func didMoveToWindow() + { + if window != nil { + updateState() + } + } + + @objc private func closeButtonTriggered() + { + owner.closeButtonTriggered(owner) + } + + @objc private func sortButtonTriggered() + { + owner.toggleSortOrder() + + updateState() + + if owner.isFollowing { + owner.follow() + } + } + + @objc private func filterButtonTriggered() + { + switch owner.minimumSeverity { + case .verbose: owner.minimumSeverity = .debug + case .debug: owner.minimumSeverity = .info + case .info: owner.minimumSeverity = .warning + case .warning: owner.minimumSeverity = .error + case .error: owner.minimumSeverity = .verbose + } + + updateState() + } + + @objc private func followButtonTriggered() + { + owner.toggleIsFollowing() + } + + private func updateState() + { + if owner.isSortedNewestFirst { + sortButton.setTitle("▲ New first", for: .normal) + } else { + sortButton.setTitle("▼ Old first", for: .normal) + } + + switch owner.minimumSeverity { + case .verbose: + filterButton.setTitle("Showing all", for: .normal) + + case .debug: + filterButton.setTitle(">=▪️debug", for: .normal) + + case .info: + filterButton.setTitle(">=🔷info", for: .normal) + + case .warning: + filterButton.setTitle(">=🔶warning", for: .normal) + + case .error: + filterButton.setTitle(">=❌error", for: .normal) + } + + updateFollowingButton() + } + + fileprivate func updateFollowingButton() + { + followButton.isSelected = owner.isFollowing + } +} + +private class LogEntryCell: UITableViewCell +{ + private weak var owner: LiveLogInspectorView? + private var logEntry: LogEntry? + private var message: String? + private var timestampNeedsRefresh = false + private let severityLabel: UILabel + private let messageLabel: UILabel + private let timestampLabel: UILabel + private let severityFormatter: SeverityLogFormatter + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) + { + severityFormatter = SeverityLogFormatter(style: .custom(textRepresentation: .colorCoded, truncateAtWidth: 1, padToWidth: 1, rightAlign: false)) + + severityLabel = UILabel() + messageLabel = UILabel() + timestampLabel = UILabel() + + super.init(style: .subtitle, reuseIdentifier: cellID) + + severityLabel.translatesAutoresizingMaskIntoConstraints = false + messageLabel.translatesAutoresizingMaskIntoConstraints = false + timestampLabel.translatesAutoresizingMaskIntoConstraints = false + + severityLabel.textAlignment = .center + + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byCharWrapping + + timestampLabel.textAlignment = .right + + backgroundColor = .clear + contentView.backgroundColor = .clear + + contentView.addSubview(severityLabel) + contentView.addSubview(messageLabel) + contentView.addSubview(timestampLabel) + + severityLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding).isActive = true + severityLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding).isActive = true + severityLabel.widthAnchor.constraint(equalToConstant: 25).isActive = true + severityLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -padding).isActive = true + + messageLabel.topAnchor.constraint(equalTo: severityLabel.topAnchor).isActive = true + messageLabel.leadingAnchor.constraint(equalTo: severityLabel.trailingAnchor, constant: padding).isActive = true + messageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding).isActive = true + messageLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -padding).isActive = true + + timestampLabel.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: padding).isActive = true + timestampLabel.leadingAnchor.constraint(equalTo: messageLabel.leadingAnchor).isActive = true + timestampLabel.trailingAnchor.constraint(equalTo: messageLabel.trailingAnchor).isActive = true + timestampLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding).isActive = true + } + + required init?(coder: NSCoder) { fatalError() } + + func set(owner: LiveLogInspectorView, logEntry: LogEntry, message: String) + { + self.owner = owner + self.logEntry = logEntry + self.message = message + + refreshDisplay() + } + + func setTimestampNeedsRefresh() + { + timestampNeedsRefresh = true + } + + func refreshDisplay() + { + severityLabel.text = logEntry.flatMap { severityFormatter.format($0) } + messageLabel.text = message + + timestampNeedsRefresh = true + refreshTimestampIfNeeded() + + owner?.styleSeverityLabel(severityLabel) + owner?.styleMessageLabel(messageLabel) + } + + func refreshTimestampIfNeeded() + { + guard timestampNeedsRefresh, + let owner = owner, + let logEntry = logEntry + else { + return + } + + let timeStr = owner.timeFormatter.string(from: logEntry.timestamp) + + if owner.calendar.isDateInToday(logEntry.timestamp) { + timestampLabel.text = timeStr + } else { + let dateStr = owner.dateFormatter.string(from: logEntry.timestamp) + timestampLabel.text = dateStr + " " + timeStr + } + + owner.styleTimestampLabel(timestampLabel) + + timestampNeedsRefresh = false + } +} + +#endif diff --git a/Sources/LiveLogInspectorViewController.swift b/Sources/LiveLogInspectorViewController.swift new file mode 100644 index 00000000..22a0651b --- /dev/null +++ b/Sources/LiveLogInspectorViewController.swift @@ -0,0 +1,79 @@ +// +// LiveLogInspectorViewController.swift +// CleanroomLogger +// +// Created by Evan Maloney on 2/18/17. +// Copyright © 2017 Gilt Groupe. All rights reserved. +// + +#if os(iOS) || os(tvOS) + +import Foundation +import UIKit + +/** + The `LiveLogInspectorViewController` provides a live view of the `LogEntry` + messages recorded by a `BufferedLogEntryMessageRecorder`. + + Typically, you would not construct a `LiveLogInspectorViewController` + directly; instead, you would add a `LiveLogInspectorConfiguration` to your + CleanroomLogger configuration and use its `inspectorViewController` + property to acquire a `LiveLogInspectorViewController` instance. + */ +open class LiveLogInspectorViewController: UIViewController +{ + /** Returns the `LiveLogInspectorView` maintained by the receiver. If the + view controller has not yet loaded its view, calling this will force the + view to be loaded. */ + open var inspectorView: LiveLogInspectorView { + return view as! LiveLogInspectorView + } + + private let recorder: BufferedLogEntryMessageRecorder + + /** + Constructs a new `LiveLogInspectorViewController` that will show a live + display of each `LogEntry` recorded by the passed-in + `BufferedLogEntryMessageRecorder`. + + - parameter recorder: The `BufferedLogEntryMessageRecorder` whose + content should be displayed by the view controller. + */ + public init(recorder: BufferedLogEntryMessageRecorder) + { + self.recorder = recorder + + super.init(nibName: nil, bundle: nil) + + modalPresentationStyle = .overFullScreen + } + + /** + Not supported. Results in a fatal error when called. + + - parameter coder: Ignored. + */ + public required init?(coder: NSCoder) { fatalError() } + + open override var preferredStatusBarStyle: UIStatusBarStyle { + return .default + } + + open override func loadView() + { + let logView = LiveLogInspectorView(recorder: recorder) + logView.closeButtonTriggered = { _ in + self.presentingViewController?.dismiss(animated: true, completion: nil) + } + view = logView + } + + open override func viewWillLayoutSubviews() + { + inspectorView.statusBarHeight = topLayoutGuide.length + + super.viewWillLayoutSubviews() + } +} + +#endif