Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

241 lines
10 KiB
Swift

import Foundation
import UIKit
import ObjectiveC
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import ChatTextInputPanelNode
import AttachmentTextInputPanelNode
import ChatTextInputActionButtonsNode
import EntityKeyboard
import GGCore
import GGHooks
/// Input panel (input_bar_editor, typing_animation, SG-F11, auto formatting, character counter).
/// Everything per layout / keystroke reads one published snapshot; nothing is written per frame
/// (the geometry editor writes on release, see GGInputBarEditor).
enum GGInputFeature {
static func register() {
GGInputBarHooks.geometry.register({
if let draft = GGInputBarEditorDraft.current {
return draft.layoutGeometry
}
return InterfaceSnapshots.shared.current.inputBarGeometry.layoutGeometry
})
GGInputPanelHooks.sendButtonSlotWidth.register({ panel, context in
return GGCharacterCounter.slotWidth(panel: panel, context: context)
})
GGInputPanelHooks.didLayout.register({ layout in
let snapshot = InterfaceSnapshots.shared.current
GGCharacterCounter.update(layout, isEnabled: snapshot.characterCounter)
// The chat panel places the toolbar under its field itself (bottomAccessory below);
// the attachment caption gets it above the field, in the panel's own coordinates.
guard let panel = layout.panel as? AttachmentTextInputPanelNode else {
return
}
if snapshot.formattingToolbar || GGFormattingToolbar.hasToolbar(panel.view) {
let presentationData = layout.context?.sharedContext.currentPresentationData.with { $0 }
guard let theme = presentationData?.theme else {
return
}
GGFormattingToolbar.update(GGFormattingToolbarRequest(hostView: panel.view, panelFrame: CGRect(origin: CGPoint(), size: CGSize(width: layout.width, height: layout.height)), isFocused: panel.isFocused, theme: theme, transition: layout.transition))
}
})
// The toolbar over a composer's top edge is outside its bounds: the composers ask for it first.
GGInputPanelHooks.overlayHitTest.register({ view, point, event in
return GGFormattingToolbar.hitTest(in: view, point: point, event: event)
})
GGInputBarHooks.toolbarHitTest.register({ view, point, event in
return GGFormattingToolbar.hitTest(in: view, point: point, event: event)
})
GGInputBarHooks.bottomAccessory.register({ request in
return GGFormattingToolbar.layoutBelow(request)
})
// The composers that are components (stories, gift message) call this point themselves.
GGInputBarHooks.formattingToolbar.register({ request in
GGFormattingToolbar.update(request)
})
GGInputPanelHooks.textDidChange.register({ panel, _ in
GGTypingAnimation.play(panel: panel)
})
// SG-G9.
GGEmojiKeyboardHooks.style.register({
return GGEmojiKeyboardStyle(emojiTabFirst: InterfaceSettings.emojiTabFirst.value, standardEmojiFirst: InterfaceSettings.standardEmojiFirst.value)
})
// Auto formatting: the whole text of a sent message / caption in one style. Last in the
// chain, after W2-3's text stages (which may change the text).
GGOutgoingHooks.textStages.register({ text, _ in
guard InterfaceSettings.autoFormatting.value, !text.text.isEmpty else {
return
}
guard let type = GGAutoFormatting.entityType(InterfaceSettings.autoFormattingStyle.value) else {
return
}
let length = (text.text as NSString).length
if text.entities.contains(where: { $0.range == 0 ..< length && $0.type == type }) {
return
}
text.entities.append(MessageTextEntity(range: 0 ..< length, type: type))
}, order: 900)
}
}
extension GGInputBarGeometry {
var layoutGeometry: GGInputBarLayoutGeometry {
return GGInputBarLayoutGeometry(
heightPadding: CGFloat(self.heightPadding),
leftMargin: CGFloat(self.leftMargin),
rightMargin: CGFloat(self.rightMargin),
bottomOffset: CGFloat(self.bottomOffset),
attachOffset: CGPoint(x: self.attachX, y: self.attachY),
accessoryOffset: CGPoint(x: self.accessoryX, y: self.accessoryY),
sendOffset: CGPoint(x: self.sendX, y: self.sendY)
)
}
}
enum GGAutoFormatting {
static let styles = ["bold", "italic", "monospace", "strikethrough", "underline", "spoiler", "quote", "code"]
static func entityType(_ style: String) -> MessageTextEntityType? {
switch style {
case "bold":
return .Bold
case "italic":
return .Italic
case "monospace":
return .Code
case "strikethrough":
return .Strikethrough
case "underline":
return .Underline
case "spoiler":
return .Spoiler
case "quote":
return .BlockQuote(isCollapsed: false)
case "code":
return .Pre(language: nil)
default:
return nil
}
}
static func title(_ style: String) -> String {
switch style {
case "bold":
return GGStrings.Interface.Input.styleBold
case "italic":
return GGStrings.Interface.Input.styleItalic
case "monospace":
return GGStrings.Interface.Input.styleMonospace
case "strikethrough":
return GGStrings.Interface.Input.styleStrikethrough
case "underline":
return GGStrings.Interface.Input.styleUnderline
case "spoiler":
return GGStrings.Interface.Input.styleSpoiler
case "quote":
return GGStrings.Interface.Input.styleQuote
default:
return GGStrings.Interface.Input.styleCode
}
}
}
private var ggCharacterCounterKey: UInt8 = 0
/// «Счётчик символов»: the length of the text from its first character (owner, build 189). While the
/// text is one line it stands right before the send button — the panel moves the emoji button left for
/// it (`GGInputPanelHooks.sendButtonSlotWidth`); past one line it goes back to the field's top-right
/// corner, above the buttons, or — when Telegram's article-editor button stands there — right before
/// that button (`GGInputPanelLayout.slot`). The font is Telegram's `Font`, so a custom font applies to it
/// as to the rest of the panel.
enum GGCharacterCounter {
/// Room on each side of the counter beyond what the panel already leaves between the glyphs.
private static let spacing: CGFloat = 3.0
static func font(baseFontSize: CGFloat) -> UIFont {
return Font.with(size: floor(baseFontSize * 13.0 / 17.0), traits: .monospacedNumbers)
}
/// The room the counter asks for in the text row, pt (0 = none).
static func slotWidth(count: Int, font: UIFont) -> CGFloat {
if count == 0 {
return 0.0
}
return ceil(("\(count)" as NSString).size(withAttributes: [.font: font]).width) + GGCharacterCounter.spacing
}
private static func baseFontSize(_ context: AccountContext?) -> CGFloat {
let presentationData = context?.sharedContext.currentPresentationData.with({ $0 })
return presentationData.flatMap { chatTextInputBaseFontSize(for: $0.chatFontSize) } ?? 17.0
}
/// GGInputPanelHooks.sendButtonSlotWidth: asked on every text measurement of the chat panel.
static func slotWidth(panel: ASDisplayNode, context: AccountContext?) -> CGFloat {
guard InterfaceSnapshots.shared.current.characterCounter, let panel = panel as? ChatTextInputPanelNode else {
return 0.0
}
return GGCharacterCounter.slotWidth(count: panel.inputTextState.inputText.string.count, font: GGCharacterCounter.font(baseFontSize: GGCharacterCounter.baseFontSize(context)))
}
static func update(_ layout: GGInputPanelLayout, isEnabled: Bool) {
let existing = objc_getAssociatedObject(layout.panel, &ggCharacterCounterKey) as? UILabel
guard isEnabled, let panel = layout.panel as? ChatTextInputPanelNode, layout.slot != .none else {
existing?.isHidden = true
return
}
let count = panel.inputTextState.inputText.string.count
guard count > 0 else {
existing?.isHidden = true
return
}
let label: UILabel
if let existing {
label = existing
} else {
label = UILabel()
label.isHidden = true
label.isUserInteractionEnabled = false
panel.view.addSubview(label)
objc_setAssociatedObject(layout.panel, &ggCharacterCounterKey, label, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
let font = GGCharacterCounter.font(baseFontSize: GGCharacterCounter.baseFontSize(layout.context))
if label.font != font {
label.font = font
}
if let presentationData = layout.context?.sharedContext.currentPresentationData.with({ $0 }) {
label.textColor = presentationData.theme.chat.inputPanel.panelControlColor
}
label.text = "\(count)"
let size = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))
let frame: CGRect
switch layout.slot {
case .none:
return
case let .beforeSendButton(rect), let .beforeCornerButton(rect):
frame = CGRect(origin: CGPoint(x: floorToScreenPixels(rect.midX - size.width * 0.5), y: floorToScreenPixels(rect.midY - size.height * 0.5)), size: size)
case let .textCorner(rect):
frame = CGRect(origin: CGPoint(x: rect.maxX - size.width - 11.0, y: rect.minY + 4.0), size: size)
}
if label.isHidden {
label.isHidden = false
label.frame = frame
} else {
layout.transition.updateFrame(view: label, frame: frame)
}
panel.view.bringSubviewToFront(label)
}
}