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

150 lines
7.8 KiB
Swift

import Foundation
// The structural replace-range.
//
// `ChatTextInputState.inputText` is a DERIVED flat projection of `ChatInputContent`, and rebuilding
// the model from a mutated copy of it (`ChatTextInputState(inputText:selectionRange:)`) re-derives
// every block through `chatInputContent(from:)` — which has no vocabulary for headings, lists,
// quotes, tables or media and so retypes them all as body paragraphs. Ten composer sites used to
// change a few characters that way, destroying the whole composer's structure as a side effect.
//
// This is the replacement: resolve a flat range to structural positions through the mapping the model
// already exposes, and splice runs into the block(s) it lands in. Everything else is untouched by
// construction.
/// The runs of a block whose text lives directly on the flat axis, or nil for a block that has none
/// (a quote, whose text is nested, or an off-axis block).
private func chatInputFlatTextRuns(of block: ChatInputBlock) -> [ChatInputRun]? {
switch block {
case let .paragraph(paragraph):
return paragraph.runs
case let .code(code):
return code.runs
case let .pullQuote(pullQuote):
return pullQuote.runs
case .blockQuote, .media, .table, .details, .buttonRow:
return nil
}
}
/// Rebuilds `block` around `runs`.
///
/// A `"\n"` splits a PARAGRAPH into siblings, because on the flat axis `"\n"` is what separates two
/// paragraphs. It does NOT split a code block or a pull quote: those carry interior newlines as
/// ordinary characters (`plainText` appends their text verbatim), so splitting one would shatter it.
private func chatInputRebuilt(_ block: ChatInputBlock, withRuns runs: [ChatInputRun]) -> [ChatInputBlock] {
switch block {
case let .paragraph(paragraph):
return chatInputRunsSplitOnNewlines(runs).map { line in
.paragraph(ChatInputParagraph(style: paragraph.style, list: paragraph.list, runs: line))
}
case let .code(code):
return [.code(ChatInputCode(language: code.language, runs: runs))]
case let .pullQuote(pullQuote):
return [.pullQuote(ChatInputPullQuote(runs: runs, author: pullQuote.author))]
case .blockQuote, .media, .table, .details, .buttonRow:
return [block]
}
}
public extension ChatInputContent {
/// The inline entity at a flat offset, or nil. The emoji-suggestion sweep uses it to stop before
/// re-replacing an occurrence it has already turned into an emoji — it searches plain text, and a
/// custom emoji's `displayText` can equal the shortcode it replaced.
func entityAt(flatOffset: Int) -> ChatInputInlineEntity? {
let position = self.position(forFlatOffset: flatOffset)
guard let step = position.path.first, self.blocks.indices.contains(step.blockIndex),
let runs = chatInputFlatTextRuns(of: self.blocks[step.blockIndex]) else {
return nil
}
var cursor = 0
for run in runs {
let length = (run.text as NSString).length
if position.offset < cursor + length {
return run.attributes.entity
}
cursor += length
}
return runs.last?.attributes.entity
}
/// Replaces the flat (`plainText`) UTF-16 range with `runs`, preserving every block outside it.
/// Returns the new content and the flat offset just past the inserted runs.
///
/// Use this instead of mutating `ChatTextInputState.inputText` and reconstructing the state — that
/// round-trip flattens the whole composer (see the file comment).
///
/// Returns the receiver unchanged when the range addresses something with no editable flat text: a
/// collapsed quote's `" "` placeholder, or a cross-block range with a quote at either end (merging
/// a quote endpoint is a further increment — refusing is visible, corrupting is not).
func replacingFlatRange(_ range: NSRange, with runs: [ChatInputRun]) -> (content: ChatInputContent, caret: Int) {
let total = (self.plainText as NSString).length
let lo = min(max(range.location, 0), total)
let hi = min(max(range.location + max(range.length, 0), lo), total)
// Uniform across every mutating branch: the flat axis counts a paragraph separator as the one
// "\n" that was already in `runs`, so the inserted extent is exactly the runs' UTF-16 length.
let caret = lo + chatInputRunsUTF16Length(runs)
let startPosition = self.position(forFlatOffset: lo)
let endPosition = self.position(forFlatOffset: hi)
guard let startStep = startPosition.path.first, let endStep = endPosition.path.first,
self.blocks.indices.contains(startStep.blockIndex),
self.blocks.indices.contains(endStep.blockIndex) else {
return (self, lo)
}
let startIndex = startStep.blockIndex
let endIndex = endStep.blockIndex
if startIndex != endIndex {
let headBlock = self.blocks[startIndex]
let tailBlock = self.blocks[endIndex]
// A quote at either end is refused rather than corrupted: merging one means either
// recursing the head quote's interior or migrating the tail quote's blocks out of it, which
// is a further increment. See the accepted limitation in the design doc.
guard let headRuns = chatInputFlatTextRuns(of: headBlock),
let tailRuns = chatInputFlatTextRuns(of: tailBlock) else {
return (self, lo)
}
let merged = chatInputRunsSlice(headRuns, fromUTF16: 0, toUTF16: startPosition.offset)
+ runs
+ chatInputRunsSlice(tailRuns, fromUTF16: endPosition.offset,
toUTF16: chatInputRunsUTF16Length(tailRuns))
// Off-axis blocks contribute neither a character nor a separator, so the flat range never
// addressed them; they keep their relative order and follow the merged block.
let survivors = self.blocks[(startIndex + 1) ..< endIndex].filter { !self.blockIsFlatParticipating($0) }
var newBlocks = self.blocks
newBlocks.replaceSubrange(startIndex ... endIndex,
with: chatInputRebuilt(headBlock, withRuns: merged) + survivors)
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
}
let block = self.blocks[startIndex]
if case let .blockQuote(quote) = block {
// A collapsed quote's placeholder is not editable text.
guard !quote.collapsed else {
return (self, lo)
}
// An expanded quote's interior is its own flat axis.
let inner = quote.content.replacingFlatRange(
NSRange(location: startPosition.offset, length: endPosition.offset - startPosition.offset),
with: runs
)
var newBlocks = self.blocks
newBlocks[startIndex] = .blockQuote(ChatInputBlockQuote(content: inner.content, collapsed: false,
author: quote.author))
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
}
guard let blockRuns = chatInputFlatTextRuns(of: block) else {
return (self, lo)
}
let merged = chatInputRunsSlice(blockRuns, fromUTF16: 0, toUTF16: startPosition.offset)
+ runs
+ chatInputRunsSlice(blockRuns, fromUTF16: endPosition.offset,
toUTF16: chatInputRunsUTF16Length(blockRuns))
var newBlocks = self.blocks
newBlocks.replaceSubrange(startIndex ... startIndex, with: chatInputRebuilt(block, withRuns: merged))
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
}
}