From bc31c8fcf488725efb779b08ed8650f3e57389a3 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 28 Feb 2019 13:13:20 -0500 Subject: [PATCH] Add brush view controller. --- Signal.xcodeproj/project.pbxproj | 8 + .../AttachmentApprovalViewController.swift | 12 +- .../ImageEditorBrushViewController.swift | 231 ++++++++++++++++++ .../ImageEditor/ImageEditorCanvasView.swift | 17 +- .../ImageEditorCropViewController.swift | 2 +- .../ImageEditor/ImageEditorPaletteView.swift | 1 + .../ImageEditorTextViewController.swift | 2 +- .../Views/ImageEditor/ImageEditorView.swift | 51 ++-- .../OWSViewController+ImageEditor.swift | 39 +++ 9 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift create mode 100644 SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index ce1384943..416e1822a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 34074F62203D0CBE004596AE /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = 34074F60203D0CBE004596AE /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34080EFE2225F96D0087E99F /* ImageEditorPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */; }; 34080F0022282C880087E99F /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */; }; + 34080F02222853E30087E99F /* ImageEditorBrushViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */; }; + 34080F04222858DC0087E99F /* OWSViewController+ImageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */; }; 340B02BA1FA0D6C700F9CFEC /* ConversationViewItemTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */; }; 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; }; 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; @@ -640,6 +642,8 @@ 34074F60203D0CBE004596AE /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSounds.h; sourceTree = ""; }; 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPaletteView.swift; sourceTree = ""; }; 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = ""; }; + 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorBrushViewController.swift; sourceTree = ""; }; + 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OWSViewController+ImageEditor.swift"; sourceTree = ""; }; 340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = ""; }; 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = ""; }; 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = ""; }; @@ -1914,6 +1918,7 @@ 34BEDB0C21C405B0007B0EAE /* ImageEditor */ = { isa = PBXGroup; children = ( + 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */, 34BBC850220B8EEF00857249 /* ImageEditorCanvasView.swift */, 34BBC853220C7ADA00857249 /* ImageEditorContents.swift */, 34BBC84E220B8A0100857249 /* ImageEditorCropViewController.swift */, @@ -1927,6 +1932,7 @@ 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */, 34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */, 34BBC856220C7ADA00857249 /* OrderedDictionary.swift */, + 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */, ); path = ImageEditor; sourceTree = ""; @@ -3390,6 +3396,7 @@ 34D5872F208E2C4200D2255A /* OWS109OutgoingMessageState.m in Sources */, 34AC09F8211B39B100997B47 /* CountryCodeViewController.m in Sources */, 451F8A341FD710C3005CB9DA /* FullTextSearcher.swift in Sources */, + 34080F04222858DC0087E99F /* OWSViewController+ImageEditor.swift in Sources */, 346129FE1FD5F31400532771 /* OWS106EnsureProfileComplete.swift in Sources */, 34AC0A10211B39EA00997B47 /* TappableView.swift in Sources */, 346129F91FD5F31400532771 /* OWS104CreateRecipientIdentities.m in Sources */, @@ -3409,6 +3416,7 @@ 346941A3215D2EE400B5BFAD /* Theme.m in Sources */, 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */, 34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */, + 34080F02222853E30087E99F /* ImageEditorBrushViewController.swift in Sources */, 34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */, 34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */, 346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */, diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index e46c5788f..4117c7783 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -279,17 +279,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC let firstViewController = viewControllers.first as? AttachmentPrepViewController { navigationBarItems = firstViewController.navigationBarItems() } - guard navigationBarItems.count > 0 else { - self.navigationItem.rightBarButtonItems = [] - return - } - - let stackView = UIStackView(arrangedSubviews: navigationBarItems) - stackView.axis = .horizontal - stackView.spacing = 8 - stackView.alignment = .center - - self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView) + updateNavigationBar(navigationBarItems: navigationBarItems) } // MARK: - View Helpers diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift new file mode 100644 index 000000000..ffcc24fe3 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift @@ -0,0 +1,231 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +//@objc +//public protocol ImageEditorViewDelegate: class { +// func imageEditor(presentFullScreenOverlay viewController: UIViewController, +// withNavigation: Bool) +// func imageEditorPresentCaptionView() +// func imageEditorUpdateNavigationBar() +//} + +// MARK: - + +@objc +public protocol ImageEditorBrushViewControllerDelegate: class { + func brushDidComplete() +} + +// MARK: - + +// A view for editing text item in image editor. +public class ImageEditorBrushViewController: OWSViewController { + + private weak var delegate: ImageEditorBrushViewControllerDelegate? + + private let model: ImageEditorModel + + private let canvasView: ImageEditorCanvasView + + private let paletteView = ImageEditorPaletteView() + + private var brushGestureRecognizer: ImageEditorPanGestureRecognizer? + + init(delegate: ImageEditorBrushViewControllerDelegate, + model: ImageEditorModel) { + self.delegate = delegate + self.model = model + self.canvasView = ImageEditorCanvasView(model: model) + + super.init(nibName: nil, bundle: nil) + + model.add(observer: self) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + // MARK: - View Lifecycle + + public override func loadView() { + self.view = UIView() + self.view.backgroundColor = .black + + canvasView.configureSubviews() + self.view.addSubview(canvasView) + canvasView.autoPinEdgesToSuperviewEdges() + + paletteView.delegate = self + self.view.addSubview(paletteView) + paletteView.autoVCenterInSuperview() + paletteView.autoPinEdge(toSuperviewEdge: .leading, withInset: 20) + + self.view.isUserInteractionEnabled = true + + let brushGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBrushGesture(_:))) + brushGestureRecognizer.maximumNumberOfTouches = 1 + brushGestureRecognizer.referenceView = canvasView.gestureReferenceView + self.view.addGestureRecognizer(brushGestureRecognizer) + self.brushGestureRecognizer = brushGestureRecognizer + + updateNavigationBar() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.view.layoutSubviews() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.layoutSubviews() + } + + public func updateNavigationBar() { + let undoButton = navigationBarButton(imageName: "image_editor_undo", + selector: #selector(didTapUndo(sender:))) + let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full", + selector: #selector(didTapDone(sender:))) + + var navigationBarItems = [UIView]() + if model.canUndo() { + navigationBarItems = [undoButton, doneButton] + } else { + navigationBarItems = [doneButton] + } + updateNavigationBar(navigationBarItems: navigationBarItems) + } + + private var currentColor: UIColor { + get { + return paletteView.selectedColor + } + } + + // MARK: - Actions + + @objc func didTapUndo(sender: UIButton) { + Logger.verbose("") + guard model.canUndo() else { + owsFailDebug("Can't undo.") + return + } + model.undo() + } + + @objc func didTapDone(sender: UIButton) { + Logger.verbose("") + + completeAndDismiss() + } + + private func completeAndDismiss() { + self.delegate?.brushDidComplete() + + self.dismiss(animated: false) { + // Do nothing. + } + } + + // MARK: - Brush + + // These properties are non-empty while drawing a stroke. + private var currentStroke: ImageEditorStrokeItem? + private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]() + + @objc + public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) { + AssertIsOnMainThread() + + let removeCurrentStroke = { + if let stroke = self.currentStroke { + self.model.remove(item: stroke) + } + self.currentStroke = nil + self.currentStrokeSamples.removeAll() + } + let tryToAppendStrokeSample = { + let view = self.canvasView.gestureReferenceView + let viewBounds = view.bounds + let locationInView = gestureRecognizer.location(in: view) + let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, + viewBounds: viewBounds, + model: self.model, + transform: self.model.currentTransform()) + + if let prevSample = self.currentStrokeSamples.last, + prevSample == newSample { + // Ignore duplicate samples. + return + } + self.currentStrokeSamples.append(newSample) + } + + let strokeColor = currentColor + // TODO: Tune stroke width. + let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth() + + switch gestureRecognizer.state { + case .began: + removeCurrentStroke() + + tryToAppendStrokeSample() + + let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.append(item: stroke) + currentStroke = stroke + + case .changed, .ended: + tryToAppendStrokeSample() + + guard let lastStroke = self.currentStroke else { + owsFailDebug("Missing last stroke.") + removeCurrentStroke() + return + } + + // Model items are immutable; we _replace_ the + // stroke item rather than modify it. + let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.replace(item: stroke, suppressUndo: true) + + if gestureRecognizer.state == .ended { + currentStroke = nil + currentStrokeSamples.removeAll() + } else { + currentStroke = stroke + } + default: + removeCurrentStroke() + } + } +} + +// MARK: - + +extension ImageEditorBrushViewController: ImageEditorModelObserver { + + public func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) { + updateNavigationBar() + } + + public func imageEditorModelDidChange(changedItemIds: [String]) { + updateNavigationBar() + } +} + +// MARK: - + +extension ImageEditorBrushViewController: ImageEditorPaletteViewDelegate { + public func selectedColorDidChange() { + // TODO: + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift index 0bf3a2703..a755ae9b9 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift @@ -59,7 +59,7 @@ public class ImageEditorCanvasView: UIView { private var imageLayer = CALayer() @objc - public func configureSubviews() -> Bool { + public func configureSubviews() { self.backgroundColor = .clear self.isOpaque = false @@ -94,8 +94,6 @@ public class ImageEditorCanvasView: UIView { contentView.autoPinEdgesToSuperviewEdges() updateLayout() - - return true } public var gestureReferenceView: UIView { @@ -631,6 +629,19 @@ public class ImageEditorCanvasView: UIView { } return nil } + + // MARK: - Coordinates + + public class func locationImageUnit(forLocationInView locationInView: CGPoint, + viewBounds: CGRect, + model: ImageEditorModel, + transform: ImageEditorTransform) -> CGPoint { + let imageFrame = self.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform) + let affineTransformStart = transform.affineTransform(viewSize: viewBounds.size) + let locationInContent = locationInView.minus(viewBounds.center).applyingInverse(affineTransformStart).plus(viewBounds.center) + let locationImageUnit = locationInContent.toUnitCoordinates(viewBounds: imageFrame, shouldClamp: false) + return locationImageUnit + } } // MARK: - diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift index b40b231c8..163b1efdf 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift @@ -738,7 +738,7 @@ class ImageEditorCropViewController: OWSViewController { private func completeAndDismiss() { self.delegate?.cropDidComplete(transform: transform) - self.dismiss(animated: true) { + self.dismiss(animated: false) { // Do nothing. } } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift index 8f222cfe8..6153b842b 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift @@ -38,6 +38,7 @@ public class ImageEditorPaletteView: UIView { private func createContents() { self.backgroundColor = .clear self.isOpaque = false + self.layoutMargins = .zero if let image = ImageEditorPaletteView.buildPaletteGradientImage() { imageView.image = image diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift index 4fd987b19..a54613579 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift @@ -204,7 +204,7 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel self.delegate?.textEditDidComplete(textItem: textItem, text: textView.text) - self.dismiss(animated: true) { + self.dismiss(animated: false) { // Do nothing. } } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index 31bd3779b..aac60ebfc 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -75,9 +75,7 @@ public class ImageEditorView: UIView { @objc public func configureSubviews() -> Bool { - guard canvasView.configureSubviews() else { - return false - } + canvasView.configureSubviews() self.addSubview(canvasView) canvasView.autoPinEdgesToSuperviewEdges() @@ -241,18 +239,6 @@ public class ImageEditorView: UIView { // MARK: - Navigation Bar - private func navigationBarButton(imageName: String, - selector: Selector) -> UIView { - let button = OWSButton() - button.setImage(imageName: imageName) - button.tintColor = .white - button.addTarget(self, action: selector, for: .touchUpInside) -// button.layer.shadowColor = UIColor.black.cgColor -// button.layer.shadowRadius = 4 -// button.layer.shadowOpacity = 0.66 - return button - } - public func navigationBarItems() -> [UIView] { let undoButton = navigationBarButton(imageName: "image_editor_undo", selector: #selector(didTapUndo(sender:))) @@ -294,6 +280,10 @@ public class ImageEditorView: UIView { Logger.verbose("") self.editorMode = .brush + + let brushView = ImageEditorBrushViewController(delegate: self, model: model) + self.delegate?.imageEditor(presentFullScreenOverlay: brushView, + withNavigation: true) } @objc func didTapCrop(sender: UIButton) { @@ -425,11 +415,11 @@ public class ImageEditorView: UIView { let viewBounds = view.bounds let locationStart = gestureRecognizer.pinchStateStart.centroid let locationNow = gestureRecognizer.pinchStateLast.centroid - let gestureStartImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationStart, + let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) - let gestureNowImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationNow, + let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -516,11 +506,11 @@ public class ImageEditorView: UIView { let view = self.canvasView.gestureReferenceView let viewBounds = view.bounds let locationInView = gestureRecognizer.location(in: view) - let gestureStartImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationStart, + let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) - let gestureNowImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationInView, + let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -564,7 +554,7 @@ public class ImageEditorView: UIView { let view = self.canvasView.gestureReferenceView let viewBounds = view.bounds let locationInView = gestureRecognizer.location(in: view) - let newSample = ImageEditorView.locationImageUnit(forLocationInView: locationInView, + let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -616,19 +606,6 @@ public class ImageEditorView: UIView { } } - // MARK: - Coordinates - - private class func locationImageUnit(forLocationInView locationInView: CGPoint, - viewBounds: CGRect, - model: ImageEditorModel, - transform: ImageEditorTransform) -> CGPoint { - let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform) - let affineTransformStart = transform.affineTransform(viewSize: viewBounds.size) - let locationInContent = locationInView.minus(viewBounds.center).applyingInverse(affineTransformStart).plus(viewBounds.center) - let locationImageUnit = locationInContent.toUnitCoordinates(viewBounds: imageFrame, shouldClamp: false) - return locationImageUnit - } - // MARK: - Edit Text Tool private func edit(textItem: ImageEditorTextItem) { @@ -760,3 +737,11 @@ extension ImageEditorView: ImageEditorPaletteViewDelegate { // TODO: } } + +// MARK: - + +extension ImageEditorView: ImageEditorBrushViewControllerDelegate { + public func brushDidComplete() { + self.editorMode = .none + } +} diff --git a/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift b/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift new file mode 100644 index 000000000..1df368d28 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +public extension NSObject { + + public func navigationBarButton(imageName: String, + selector: Selector) -> UIView { + let button = OWSButton() + button.setImage(imageName: imageName) + button.tintColor = .white + button.addTarget(self, action: selector, for: .touchUpInside) + // button.layer.shadowColor = UIColor.black.cgColor + // button.layer.shadowRadius = 4 + // button.layer.shadowOpacity = 0.66 + return button + } +} + +// MARK: - + +public extension UIViewController { + + public func updateNavigationBar(navigationBarItems: [UIView]) { + guard navigationBarItems.count > 0 else { + self.navigationItem.rightBarButtonItems = [] + return + } + + let stackView = UIStackView(arrangedSubviews: navigationBarItems) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView) + } +}