import Foundation import UIKit import Display import TelegramPresentationData import ComponentFlow import MultilineTextComponent import TelegramCore import EmojiStatusComponent import AccountContext import BundleIconComponent import GlassBackgroundComponent import ComponentDisplayAdapters final class TextProcessingStyleSelectionComponent: Component { let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let styles: [TextProcessingScreen.Style] let selectedStyle: TelegramComposeAIMessageMode.StyleId let updateStyle: (TelegramComposeAIMessageMode.StyleReference) -> Void let createStyle: () -> Void let openStyleContextMenu: (TelegramComposeAIMessageMode.StyleReference, ContextGesture, ContextExtractedContentContainingView) -> Void init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, styles: [TextProcessingScreen.Style], selectedStyle: TelegramComposeAIMessageMode.StyleId, updateStyle: @escaping (TelegramComposeAIMessageMode.StyleReference) -> Void, createStyle: @escaping () -> Void, openStyleContextMenu: @escaping (TelegramComposeAIMessageMode.StyleReference, ContextGesture, ContextExtractedContentContainingView) -> Void ) { self.context = context self.theme = theme self.strings = strings self.styles = styles self.selectedStyle = selectedStyle self.updateStyle = updateStyle self.createStyle = createStyle self.openStyleContextMenu = openStyleContextMenu } static func ==(lhs: TextProcessingStyleSelectionComponent, rhs: TextProcessingStyleSelectionComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.styles != rhs.styles { return false } if lhs.selectedStyle != rhs.selectedStyle { return false } return true } private final class ScrollView: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } } final class View: UIView { private var component: TextProcessingStyleSelectionComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private let contextGestureContainerView: ContextControllerSourceView private let scrollView: ScrollView private var itemViews: [TelegramComposeAIMessageMode.StyleId: ComponentView] = [:] private let createStyleItemView = ComponentView() private let selectedBackgroundView: UIImageView private var itemWithActiveContextGesture: TelegramComposeAIMessageMode.StyleId? override init(frame: CGRect) { self.contextGestureContainerView = ContextControllerSourceView() self.contextGestureContainerView.isGestureEnabled = true self.contextGestureContainerView.useSublayerTransformForActivation = true self.scrollView = ScrollView() self.selectedBackgroundView = UIImageView() self.selectedBackgroundView.isHidden = true self.selectedBackgroundView.alpha = 0.0 super.init(frame: frame) self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.contentInsetAdjustmentBehavior = .never self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = false self.scrollView.scrollsToTop = false self.scrollView.clipsToBounds = false self.addSubview(self.contextGestureContainerView) self.scrollView.addSubview(self.selectedBackgroundView) self.contextGestureContainerView.addSubview(self.scrollView) self.scrollView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))) self.contextGestureContainerView.shouldBegin = { [weak self] point in guard let self, let component = self.component else { return false } guard let (itemId, itemView) = self.item(at: point) else { return false } guard let item = component.styles.first(where: { .style($0.reference.id) == itemId }) else { return false } guard case .custom = item.reference else { return false } guard let itemComponentView = itemView.view as? ItemComponent.View else { return false } self.itemWithActiveContextGesture = itemId self.contextGestureContainerView.targetLayerForActivationProgress = itemComponentView.containerView.layer let startPoint = point self.contextGestureContainerView.contextGesture?.externalUpdated = { [weak self] _, point in guard let self else { return } let dist = sqrt(pow(startPoint.x - point.x, 2.0) + pow(startPoint.y - point.y, 2.0)) if dist > 10.0 { self.contextGestureContainerView.contextGesture?.cancel() } } return true } self.contextGestureContainerView.activated = { [weak self] gesture, _ in guard let self, let component = self.component else { return } guard let itemWithActiveContextGesture = self.itemWithActiveContextGesture else { return } var itemView: ItemComponent.View? itemView = self.itemViews[itemWithActiveContextGesture]?.view as? ItemComponent.View guard let itemView else { return } guard let item = component.styles.first(where: { .style($0.reference.id) == itemWithActiveContextGesture }) else { return } component.openStyleContextMenu(item.id, gesture, itemView.contextContainerView) } } required init?(coder: NSCoder) { preconditionFailure() } private func item(at point: CGPoint) -> (id: TelegramComposeAIMessageMode.StyleId, itemView: ComponentView)? { for (id, itemView) in self.itemViews { if let itemComponentView = itemView.view { if itemComponentView.bounds.contains(self.scrollView.convert(self.convert(point, to: self.scrollView), to: itemComponentView)) { return (id, itemView) } } } return nil } @objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) { guard let component = self.component else { return } if case .ended = recognizer.state { for (id, itemView) in self.itemViews { if let itemComponentView = itemView.view { if itemComponentView.bounds.contains(self.scrollView.convert(recognizer.location(in: self.scrollView), to: itemComponentView)) { if component.selectedStyle == id { component.updateStyle(.neutral) } else { if let style = component.styles.first(where: { .style($0.reference.id) == id }) { component.updateStyle(.style(style.reference)) } } self.scrollView.scrollRectToVisible(itemComponentView.frame.insetBy(dx: -100.0, dy: 0.0), animated: true) break } } } if let itemComponentView = self.createStyleItemView.view { if itemComponentView.bounds.contains(self.scrollView.convert(recognizer.location(in: self.scrollView), to: itemComponentView)) { component.createStyle() } } } } func scrollToStart() { let transition: ComponentTransition = .spring(duration: 0.4) transition.setBounds(view: self.scrollView, bounds: CGRect(origin: CGPoint(), size: self.scrollView.bounds.size)) } func update(component: TextProcessingStyleSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) self.component = component self.state = state let maxItemWidth: CGFloat = 80.0 let itemPadding: CGFloat = 0.0 let minSlotWidth: CGFloat = 50.0 let maxSlotWidth: CGFloat = 80.0 // First pass: measure all items to find intrinsic sizes var itemSizes: [TelegramComposeAIMessageMode.StyleId: CGSize] = [:] for i in 0 ..< component.styles.count { let style = component.styles[i] let itemView: ComponentView var itemTransition = transition if let current = self.itemViews[.style(style.reference.id)] { itemView = current } else { itemTransition = itemTransition.withAnimation(.none) itemView = ComponentView() self.itemViews[.style(style.reference.id)] = itemView } let measuredSize = itemView.update( transition: itemTransition, component: AnyComponent(ItemComponent( context: component.context, theme: component.theme, iconFileId: style.emojiFileId, iconFile: style.emojiFile, title: style.title )), environment: {}, containerSize: CGSize(width: maxItemWidth, height: availableSize.height) ) itemSizes[.style(style.reference.id)] = measuredSize } let createStyleItemSize: CGSize = self.createStyleItemView.update( transition: transition, component: AnyComponent(CreateItemComponent( theme: component.theme, strings: component.strings )), environment: {}, containerSize: CGSize(width: maxItemWidth, height: availableSize.height) ) // Compute uniform slot width from largest item var largestItemWidth: CGFloat = 0.0 for (_, size) in itemSizes { largestItemWidth = max(largestItemWidth, size.width) } largestItemWidth = max(largestItemWidth, createStyleItemSize.width) let contentBasedWidth = min(maxSlotWidth, max(minSlotWidth, largestItemWidth + itemPadding)) let slotWidth: CGFloat if CGFloat(component.styles.count + 1) * contentBasedWidth <= availableSize.width { slotWidth = floor(availableSize.width / CGFloat(component.styles.count + 1)) } else { var resolved: CGFloat = contentBasedWidth var targetVisible: CGFloat = min(7.5, floor((availableSize.width + 16.0) / (contentBasedWidth + 10.0)) + 0.5) while targetVisible >= 1.5 { let candidateWidth = floor((availableSize.width + 16.0) / targetVisible) if candidateWidth >= contentBasedWidth { resolved = candidateWidth break } targetVisible -= 1.0 } slotWidth = resolved } let contentWidth = slotWidth * CGFloat(component.styles.count + 1) self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) self.scrollView.contentSize = CGSize(width: contentWidth, height: availableSize.height) self.scrollView.alwaysBounceHorizontal = contentWidth > availableSize.width self.contextGestureContainerView.frame = CGRect(origin: CGPoint(), size: availableSize) // Second pass: position items centered in their slots var selectedItemFrame: CGRect? for i in 0 ..< component.styles.count { let style = component.styles[i] guard let itemView = self.itemViews[.style(style.reference.id)], let naturalSize = itemSizes[.style(style.reference.id)] else { continue } let itemSize = CGSize(width: slotWidth, height: naturalSize.height) let itemFrame = CGRect(origin: CGPoint(x: CGFloat(i) * slotWidth, y: 0.0), size: itemSize) if let itemComponentView = itemView.view as? ItemComponent.View { var itemTransition = transition if itemComponentView.superview == nil { self.scrollView.addSubview(itemComponentView) itemTransition = itemTransition.withAnimation(.none) transition.animateScale(view: itemComponentView, from: 0.001, to: 1.0) transition.animateAlpha(view: itemComponentView, from: 0.0, to: 1.0) } itemTransition.setFrame(view: itemComponentView, frame: itemFrame) itemComponentView.applySize(measuredSize: naturalSize, size: itemSize, transition: itemTransition) } if .style(style.reference.id) == component.selectedStyle { selectedItemFrame = CGRect(origin: CGPoint(x: itemFrame.minX, y: -5.0), size: CGSize(width: slotWidth, height: availableSize.height + 5.0 + 3.0)) } } do { let naturalSize = createStyleItemSize let slotOriginX = CGFloat(component.styles.count) * slotWidth let itemX = slotOriginX + floor((slotWidth - naturalSize.width) * 0.5) let itemFrame = CGRect(origin: CGPoint(x: itemX, y: 0.0), size: naturalSize) if let itemComponentView = self.createStyleItemView.view { if itemComponentView.superview == nil { self.scrollView.addSubview(itemComponentView) } transition.setFrame(view: itemComponentView, frame: itemFrame) } } var removedIds: [TelegramComposeAIMessageMode.StyleId] = [] for (id, itemView) in self.itemViews { if !component.styles.contains(where: { .style($0.reference.id) == id }) { removedIds.append(id) if let itemComponentView = itemView.view { transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in itemComponentView?.removeFromSuperview() }) transition.setScale(view: itemComponentView, scale: 0.001) } } } for id in removedIds { self.itemViews.removeValue(forKey: id) } if self.selectedBackgroundView.image == nil { self.selectedBackgroundView.image = generateStretchableFilledCircleImage(diameter: 16.0 * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate) } self.selectedBackgroundView.tintColor = component.theme.list.itemHighlightedBackgroundColor.withMultipliedAlpha(0.6) if let selectedItemFrame { var selectedBackgroundTransition = transition if self.selectedBackgroundView.isHidden { self.selectedBackgroundView.isHidden = false selectedBackgroundTransition = selectedBackgroundTransition.withAnimation(.none) } selectedBackgroundTransition.setFrame(view: self.selectedBackgroundView, frame: selectedItemFrame) alphaTransition.setAlpha(view: self.selectedBackgroundView, alpha: 1.0) } else { if !self.selectedBackgroundView.isHidden { alphaTransition.setAlpha(view: self.selectedBackgroundView, alpha: 0.0, completion: { [weak self] flag in guard let self, flag else { return } self.selectedBackgroundView.isHidden = true }) } } return CGSize(width: availableSize.width, height: availableSize.height) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class ItemComponent: Component { let context: AccountContext let theme: PresentationTheme let iconFileId: Int64? let iconFile: TelegramMediaFile? let title: String init( context: AccountContext, theme: PresentationTheme, iconFileId: Int64?, iconFile: TelegramMediaFile?, title: String ) { self.context = context self.theme = theme self.iconFileId = iconFileId self.iconFile = iconFile self.title = title } static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.iconFileId != rhs.iconFileId { return false } if lhs.iconFile?.fileId != rhs.iconFile?.fileId { return false } if lhs.title != rhs.title { return false } return true } final class View: UIView { let contextContainerView: ContextExtractedContentContainingView let containerView: UIView private let backgroundContainer: GlassBackgroundContainerView private let backgroundView: GlassBackgroundView private var imageIcon: ComponentView? private let title = ComponentView() private var component: ItemComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.contextContainerView = ContextExtractedContentContainingView() self.containerView = UIView() self.backgroundContainer = GlassBackgroundContainerView() self.backgroundContainer.alpha = 0.0 self.backgroundView = GlassBackgroundView() self.backgroundContainer.contentView.addSubview(self.backgroundView) super.init(frame: frame) self.addSubview(self.contextContainerView) self.contextContainerView.contentView.addSubview(self.backgroundContainer) self.contextContainerView.contentView.addSubview(self.containerView) self.contextContainerView.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in guard let self else { return } let transition: ComponentTransition = transition.isAnimated ? .easeInOut(duration: 0.25) : .immediate if isExtracted { transition.setAlpha(view: self.backgroundContainer, alpha: 1.0) } else { transition.setAlpha(view: self.backgroundContainer, alpha: 0.001) } } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func applySize(measuredSize: CGSize, size: CGSize, transition: ComponentTransition) { guard let component = self.component else { return } let containerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - measuredSize.width) * 0.5), y: floor((measuredSize.height - size.height) * 0.5)), size: measuredSize) let contentRect = CGRect(origin: CGPoint(x: 0.0, y: -5.0 - 4.0), size: CGSize(width: size.width + 0.0, height: size.height + 5.0 + 3.0 + 6.0)) transition.setFrame(view: self.backgroundContainer, frame: contentRect) self.backgroundContainer.update(size: contentRect.size, isDark: component.theme.overallDarkAppearance, transition: transition) transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: contentRect.size)) self.backgroundView.update(size: contentRect.size, cornerRadius: 20.0, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel), transition: transition) transition.setFrame(view: self.containerView, frame: containerFrame) transition.setFrame(view: self.contextContainerView, frame: CGRect(origin: CGPoint(), size: size)) transition.setFrame(view: self.contextContainerView.contentView, frame: CGRect(origin: CGPoint(), size: size)) self.contextContainerView.contentRect = contentRect.insetBy(dx: -2.0, dy: -2.0) } func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousComponent = self.component self.component = component self.state = state let iconTintColor = component.theme.list.itemPrimaryTextColor if previousComponent?.iconFileId != component.iconFileId { if let imageIcon = self.imageIcon { self.imageIcon = nil imageIcon.view?.removeFromSuperview() } } let imageIcon: ComponentView var iconTransition = transition if let current = self.imageIcon { imageIcon = current } else { iconTransition = iconTransition.withAnimation(.none) imageIcon = ComponentView() self.imageIcon = imageIcon } let iconComponent: AnyComponent if let iconFileId = component.iconFileId { let iconSize = CGSize(width: 34.0, height: 34.0) let content: EmojiStatusComponent.AnimationContent if let file = component.iconFile { content = .file(file: file) } else { content = .customEmoji(fileId: iconFileId) } iconComponent = AnyComponent(EmojiStatusComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, content: .animation( content: content, size: iconSize, placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(0) ), size: iconSize, isVisibleForAnimations: true, action: nil )) } else { iconComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: "❌", font: Font.regular(25.0), textColor: .black)) )) } let iconSize = imageIcon.update( transition: .immediate, component: iconComponent, environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.medium(10.0), textColor: iconTintColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) let contentWidth = max(iconSize.width, titleSize.width) let iconFrame = CGRect(origin: CGPoint(x: floor((contentWidth - iconSize.width) * 0.5), y: -3.0), size: iconSize) if let imageIconView = imageIcon.view { if imageIconView.superview == nil { self.containerView.addSubview(imageIconView) } iconTransition.setFrame(view: imageIconView, frame: iconFrame) } let titleFrame = CGRect(origin: CGPoint(x: floor((contentWidth - titleSize.width) * 0.5), y: availableSize.height - 5.0 - titleSize.height), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.containerView.addSubview(titleView) } titleView.frame = titleFrame } let size = CGSize(width: contentWidth, height: availableSize.height) return size } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class CreateItemComponent: Component { let theme: PresentationTheme let strings: PresentationStrings init( theme: PresentationTheme, strings: PresentationStrings ) { self.theme = theme self.strings = strings } static func ==(lhs: CreateItemComponent, rhs: CreateItemComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } return true } final class View: UIView { private let icon = ComponentView() private let title = ComponentView() private var component: CreateItemComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: CreateItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let iconTintColor = component.theme.list.itemPrimaryTextColor let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: "TextProcessing/NewStyle", tintColor: iconTintColor )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) //TODO:localize let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: "Add Style", font: Font.medium(10.0), textColor: iconTintColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) let contentWidth = max(iconSize.width, titleSize.width) let iconFrame = CGRect(origin: CGPoint(x: floor((contentWidth - iconSize.width) * 0.5), y: -3.0), size: iconSize) if let iconView = self.icon.view { if iconView.superview == nil { self.addSubview(iconView) } transition.setFrame(view: iconView, frame: iconFrame) } let titleFrame = CGRect(origin: CGPoint(x: floor((contentWidth - titleSize.width) * 0.5), y: availableSize.height - 5.0 - titleSize.height), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } titleView.frame = titleFrame } return CGSize(width: contentWidth, height: availableSize.height) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }