From 1872832e286c7124de023c32706a741ff9906f0f Mon Sep 17 00:00:00 2001 From: isaac <> Date: Mon, 1 Jun 2026 17:33:01 +0200 Subject: [PATCH] Improve InstantPage V2 layout: true font-height line boxes + code-block style - layoutTextItem now sizes a text item to the true font line height (A + D) instead of the cap box: the line stack starts at lineBoxTopInset (the ascender headroom, max(0, fontAscent - fontLineHeight)) and the returned height is padded by the last line's descender. Inter-line advance is unchanged and per-line frames stay the cap box, so the baseline draw, decorations, reveal mask, and inline attachments translate consistently; the page grows. - Size inline custom emoji to ~the font line height (font.ascender - font.descender + 4*pointSize/17) so they fit the taller line box instead of overflowing it; the line is not inflated. - Add a dedicated code-block text style for rich messages (monospace font + codeBlock theme attribute) threaded through the rich-data bubble and send preview; unify the bubble code-block/separator colors. - Adjust inter-block spacings and assorted V2 layout/divider details. - Document the true-font-height box and emoji sizing in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 18 +++++- .../Sources/InstantPageLayout.swift | 20 ++++-- .../Sources/InstantPageLayoutSpacings.swift | 32 +++++----- .../Sources/InstantPageTextItem.swift | 6 +- .../Sources/InstantPageTheme.swift | 55 ++++++++++------- .../Sources/InstantPageV2Layout.swift | 61 +++++++++++++------ ...ChatMessageRichDataBubbleContentNode.swift | 40 ++++++++---- .../Chat/ChatSendMessageRichTextPreview.swift | 5 +- 8 files changed, 154 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4beaab8c0..2f253bc82c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th - **X is a fixed left edge, not the last line's `minX`.** Anchor x = `pageHorizontalInset` (10pt, the page layout's text inset; pageView sits at self-x 0). The status layout is measured with `boundingWidth - 2·pageHorizontalInset` (mirrors TextBubble's `boundingWidth - sideInsets`) so the right-aligned date lands at the right inset instead of off the bubble. Using `lastTextLineFrame.minX` (which is large for nested/indented last lines) shoved the date off to the right. - **Trail the last line only when the bottom-most item is text.** `lastTextLineFrameIfLastItemIsText(in:)` (in `InstantPageV2Layout.swift`) returns the last line frame *only* when the bottom-most top-level item (max `maxY`) is a `.text`; otherwise nil, so the date wraps below all content (anchored at `contentSize.height`). For tables/images/etc. the date must not trail text buried above the final item. -- **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (an inline animated emoji, ~`pointSize·24/17`, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case. +- **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (a tall inline attachment, e.g. a formula, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case. - **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY` in the apply closure exactly, or the date will be clipped/misplaced. (`streamingHeaderOffset` is `0.0` — there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset. - **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant. @@ -109,6 +109,18 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter - **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance. - **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll. +## InstantPage V2 text item height (true font line box) + +`layoutTextItem` (`InstantPageV2Layout.swift`) sizes a `.text` item to the **true font line height**, not the cap box. A single-line item measures exactly `fontAscent + fontDescentBelowBaseline` (`A + D`); the old behavior was the cap box `fontLineHeight = floor(fontAscent + fontDescent)` (`A − D`). + +### Non-obvious invariants + +- **Two edits in `layoutTextItem`:** the line stack starts at `lineBoxTopInset = max(0, fontAscent − fontLineHeight)` (was `0`), and the returned height is `lines.last.frame.maxY + extraDescent + fontDescentBelowBaseline` (the `+ fontDescentBelowBaseline` contains the last line's descender). Net: every text item grows ~`(A − L) + D` (~8pt @17pt) and its glyphs draw ~`lineBoxTopInset` (~4pt) lower within their box; the page grows. +- **Per-line frames stay the cap box** (`height = lineAscent = fontLineHeight`). Only the stack's starting origin moves and the total is padded — so the baseline is still drawn at each line frame's `maxY`, inter-line advance (`lineAscent + fontLineSpacing + extraDescent`) is unchanged, and decorations / inline attachments / `characterRect` / the reveal mask (all line-frame-relative) translate consistently. +- **`lineBoxTopInset` is exact, NOT pixel-snapped** — it is an intra-item line offset; crispness rides on the item's own pixel-snapped frame origin (intra-item line positions may already be fractional, e.g. after a non-integral `extraDescent`). +- **Formulas / tall inline content still inflate** via `lineAscent`/`extraDescent`; the `"\u{200b}"`+anchors `height = 0` case is preserved. +- **Inline custom emoji are sized to ≈ the line box** so they fit the taller box rather than overflowing it (see "Inline custom emoji"). + ## Inline custom emoji (RichText.textCustomEmoji) `RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above. (The **send / edit / copy / paste** round-trip that produces `.textCustomEmoji` from typed markdown is a separate section below: "Custom emoji in markdown messages".) @@ -120,7 +132,7 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter | `submodules/TelegramCore/Sources/SyncCore/SyncCore_RichText.swift` | Enum case `textCustomEmoji(fileId: Int64, alt: String)` + Postbox coding (discriminator 17, keys `ce.f`/`ce.a`), `==`, `plainText` (returns `alt`), and FlatBuffers codec. | | `submodules/TelegramCore/FlatSerialization/Models/RichText.fbs` | FlatBuffers schema — `RichText_CustomEmoji` union member + table. **Source of truth**; the Bazel `flatc` genrule regenerates `*_generated.swift` at build time (the checked-in `Sources/*_generated.swift` is stale). | | `submodules/TelegramCore/Sources/ApiUtils/RichText.swift` | `Api.RichText.textCustomEmoji` ⇄ Swift, lossless both ways. | -| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Emits a single placeholder char carrying `ChatTextInputAttributes.customEmoji` (a `ChatTextInputTextCustomEmojiAttribute`) + a `CTRunDelegate` sized `font.pointSize · 24/17`. | +| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` (`attributedStringForRichText`) | Emits a single placeholder char carrying `ChatTextInputAttributes.customEmoji` (a `ChatTextInputTextCustomEmojiAttribute`) + a `CTRunDelegate` sized to the font line height (`font.ascender − font.descender + 4·pointSize/17` ≈ 24pt @17pt). | | `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (line-breaker) | Collects per-line `InstantPageTextLine.emojiItems`; overwrites each placeholder char's `characterRect` with a full cell (`width = itemSize`) so it feeds the reveal cost map. | | `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2View`) | Owns the `InlineStickerItemLayer`s: `updateInlineEmoji` (create/reuse/remove/position), `updateEmojiReveal` (reveal-driven pop-in), `updateEmojiVisibility` + `propagateVisibilityRect`. Layers attach to each text view's `emojiContainerView`. | @@ -129,7 +141,7 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter - **flatc casing/`required` gotchas.** Edit `RichText.fbs`, not the generated Swift. Scalars (`long`) cannot be `(required)` — only strings/tables can. A union member `RichText_CustomEmoji` generates the Swift enum case `.richtextCustomemoji` (everything after the suffix's first letter is lowercased); the table type stays `TelegramCore_RichText_CustomEmoji` and field accessors keep `.fbs` casing (`value.fileId`). See the `flatbuffers-codegen` memory. - **`ChatTextInputTextCustomEmojiAttribute` is reused end-to-end** (display layer ⇄ layout model). The attribute is written to the placeholder in `attributedStringForRichText` and read back by the V2 line-breaker under the SAME key (`ChatTextInputAttributes.customEmoji`); `InlineStickerItemLayer.init` consumes it directly and resolves the file lazily from `fileId`. - **Emoji participates in the streaming reveal.** Its placeholder char's `characterRect` is overwritten to a full cell (width = `itemSize`), so the width-based cost map charges it like other content. `updateEmojiReveal` pops the layer in (alpha 0→1 + scale) when `charIndexInItem < currentRevealCharacterCount`; unrevealed → opacity 0. -- **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so a 24pt emoji on a ~17pt line bleeds symmetrically instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`. +- **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Custom emoji are sized to ≈ the line box (`size = font.ascender − font.descender + 4·pointSize/17`) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`. - **Layers sit ABOVE the reveal mask.** They attach to `InstantPageV2TextView.emojiContainerView` (a sibling above `renderContainer`), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead. - **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is now REUSED across `stableVersion` bumps (see streaming section), so the inline-emoji dict PERSISTS across chunks; `updateInlineEmoji` prunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass. - **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set. `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`. diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 1a9ff938f4..3a149628f7 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -35,10 +35,12 @@ private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantP stack.push(.linkColor(theme.linkColor)) stack.push(.linkMarkerColor(theme.linkHighlightColor)) switch attributes.font.style { - case .sans: - stack.push(.fontSerif(false)) - case .serif: - stack.push(.fontSerif(true)) + case .sans: + stack.push(.fontSerif(false)) + case .serif: + stack.push(.fontSerif(true)) + case .monospace: + stack.push(.fontFixed(true)) } stack.push(.fontSize(attributes.font.size)) stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor)) @@ -85,6 +87,16 @@ private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = fals } else { return Font.regular(size) } + case .monospace: + if bold && italic { + return Font.semiboldItalicMonospace(size) + } else if bold { + return Font.semiboldMonospace(size) + } else if italic { + return Font.italicMonospace(size) + } else { + return Font.monospace(size) + } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift index bd1e281844..d307fd9a50 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift @@ -16,7 +16,7 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi return 0.0 case (.divider, _), (_, .divider): if fitToWidth { - return 20.0 + return 21.0 } else { return 25.0 } @@ -54,18 +54,14 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi } else { return 31.0 } - case (.preformatted, .paragraph): - return 19.0 case (.formula, .paragraph): return 19.0 case (.paragraph, .paragraph): if fitToWidth { - return 10.0 + return 2.0 } else { return 25.0 } - case (_, .paragraph): - return 20.0 case (.title, .formula), (.authorDate, .formula): return 34.0 case (.header, .formula), (.subheader, .formula), (.heading, .formula): @@ -76,8 +72,6 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi } case (.list, .formula): return 31.0 - case (.preformatted, .formula): - return 19.0 case (.paragraph, .formula): return 19.0 case (_, .formula): @@ -86,8 +80,12 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi return 34.0 case (.header, .list), (.subheader, .list), (.heading, .list): return 31.0 - case (.preformatted, .list): - return 19.0 + case (.preformatted, _), (_, .preformatted): + if fitToWidth { + return 12.0 + } else { + return 19.0 + } case (.formula, .list): if fitToWidth { return 10.0 @@ -100,12 +98,6 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi } else { return 25.0 } - case (.paragraph, .preformatted): - return 19.0 - case (.formula, .preformatted): - return 19.0 - case (_, .preformatted): - return 20.0 case (_, .header), (_, .subheader), (_, .heading): return 32.0 default: @@ -121,9 +113,11 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi case .topLevel: switch lower { case .heading: - return 13.0 - default: + return 6.0 + case .table: return 10.0 + default: + return 5.0 } case .cell: return 0.0 @@ -139,6 +133,8 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi case .topLevel: if case .relatedArticles = upper { return 0.0 + } else if case .thinking = upper { + return 2.0 } else { if fitToWidth { return 5.0 diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index f3a32116cf..b7c1a0c4cc 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -960,7 +960,11 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt } let attributes = styleStack.textAttributes() let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0) - let itemSize = font.pointSize * 24.0 / 17.0 + // Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt + // body font (scaled proportionally). Must match the V2 layout's emoji cell size + // (InstantPageV2Layout.swift). The run delegate still reports the font's own + // ascent/descent (below), so the line height is unchanged — only the emoji width changes. + let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0 let extentBuffer = UnsafeMutablePointer.allocate(capacity: 1) extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize)) var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { pointer in diff --git a/submodules/InstantPageUI/Sources/InstantPageTheme.swift b/submodules/InstantPageUI/Sources/InstantPageTheme.swift index a1857d1ef5..d3cd70e803 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTheme.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTheme.swift @@ -7,6 +7,7 @@ import TelegramUIPreferences public enum InstantPageFontStyle { case sans case serif + case monospace } public struct InstantPageFont { @@ -50,6 +51,7 @@ enum InstantPageTextCategoryType { case credit case table case article + case codeBlock } public struct InstantPageTextCategories { @@ -61,8 +63,9 @@ public struct InstantPageTextCategories { let credit: InstantPageTextAttributes let table: InstantPageTextAttributes let article: InstantPageTextAttributes + let codeBlock: InstantPageTextAttributes - public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes) { + public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes, codeBlock: InstantPageTextAttributes) { self.kicker = kicker self.header = header self.subheader = subheader @@ -71,26 +74,29 @@ public struct InstantPageTextCategories { self.credit = credit self.table = table self.article = article + self.codeBlock = codeBlock } func attributes(type: InstantPageTextCategoryType, link: Bool) -> InstantPageTextAttributes { switch type { - case .kicker: - return self.kicker.withUnderline(link) - case .header: - return self.header.withUnderline(link) - case .subheader: - return self.subheader.withUnderline(link) - case .paragraph: - return self.paragraph.withUnderline(link) - case .caption: - return self.caption.withUnderline(link) - case .credit: - return self.credit.withUnderline(link) - case .table: - return self.table.withUnderline(link) - case .article: - return self.article.withUnderline(link) + case .kicker: + return self.kicker.withUnderline(link) + case .header: + return self.header.withUnderline(link) + case .subheader: + return self.subheader.withUnderline(link) + case .paragraph: + return self.paragraph.withUnderline(link) + case .caption: + return self.caption.withUnderline(link) + case .credit: + return self.credit.withUnderline(link) + case .table: + return self.table.withUnderline(link) + case .article: + return self.article.withUnderline(link) + case .codeBlock: + return self.codeBlock.withUnderline(link) } } @@ -103,7 +109,8 @@ public struct InstantPageTextCategories { caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), - article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif) + article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + codeBlock: self.codeBlock.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif) ) } } @@ -214,7 +221,8 @@ private let lightTheme = InstantPageTheme( caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: .black), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: .black) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: .black), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: .black) ), serif: false, codeBlockBackgroundColor: UIColor(rgb: 0xf5f8fc), @@ -247,7 +255,8 @@ private let sepiaTheme = InstantPageTheme( caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)) ), serif: false, codeBlockBackgroundColor: UIColor(rgb: 0xefe7d6), @@ -280,7 +289,8 @@ private let grayTheme = InstantPageTheme( caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)) ), serif: false, codeBlockBackgroundColor: UIColor(rgb: 0x555556), @@ -313,7 +323,8 @@ private let darkTheme = InstantPageTheme( caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)) ), serif: false, codeBlockBackgroundColor: UIColor(rgb: 0x131313), diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index d160481213..b651dcd81b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -440,7 +440,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? { /// Also returns `trailingBottomPadding`: the renderer draws the baseline at the line frame's maxY, /// so the visible text of a plain line sits ~5pt below it. A status that *trails on the line* should /// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad -/// is 0 when the line is taller than its font line height (an inline animated emoji, ~pointSize·24/17, +/// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula, /// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status /// wraps onto its own line below the text — there it should sit at the bare maxY. public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? { @@ -1899,17 +1899,14 @@ private func layoutDivider( boundingWidth: CGFloat, context: LayoutContext ) -> [InstantPageV2LaidOutItem] { - // Geometry matches V1 InstantPageLayout.swift lines 361–363: - // lineWidth = floor(boundingWidth / 2.0), x = floor((boundingWidth - lineWidth) / 2.0), h = 1pt. - // Color matches V1: theme.textCategories.caption.color. let lineWidth = floor(boundingWidth / 2.0) let frame = CGRect( x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0, width: lineWidth, - height: 1.0 + height: UIScreenPixel ) - return [.divider(InstantPageV2DividerItem(frame: frame, color: context.theme.textCategories.caption.color))] + return [.divider(InstantPageV2DividerItem(frame: frame, color: context.theme.separatorColor))] } // MARK: - Code block layout (ported from V1 InstantPageLayout.swift lines 329–351) @@ -1921,11 +1918,8 @@ private func layoutCodeBlock( horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { - // V1 InstantPageLayout.swift line 330: backgroundInset = 14.0 (top + bottom padding). - let backgroundInset: CGFloat = 14.0 - // V1 line 342: text x offset is 17.0 (hardcoded, not backgroundInset). - let textXOffset: CGFloat = 17.0 - // V1 line 348: shape is .rect — no corner radius. + let backgroundInset: CGFloat = 15.0 + let textXOffset: CGFloat = 11.0 let cornerRadius: CGFloat = 0.0 let attributedString: NSAttributedString @@ -1940,7 +1934,7 @@ private func layoutCodeBlock( } else { // V1 lines 335–338: fall back to plain paragraph style when no language. let styleStack = InstantPageTextStyleStack() - setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) + setupStyleStack(styleStack, theme: context.theme, category: .codeBlock, link: false) attributedString = attributedStringForRichText(text, styleStack: styleStack) } @@ -2528,10 +2522,12 @@ private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantP stack.push(.linkColor(theme.linkColor)) stack.push(.linkMarkerColor(theme.linkHighlightColor)) switch attributes.font.style { - case .sans: - stack.push(.fontSerif(false)) - case .serif: - stack.push(.fontSerif(true)) + case .sans: + stack.push(.fontSerif(false)) + case .serif: + stack.push(.fontSerif(true)) + case .monospace: + stack.push(.fontFixed(true)) } stack.push(.fontSize(attributes.font.size)) stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor)) @@ -2578,6 +2574,16 @@ private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = fals } else { return Font.regular(size) } + case .monospace: + if bold && italic { + return Font.semiboldItalicMonospace(size) + } else if bold { + return Font.semiboldMonospace(size) + } else if italic { + return Font.italicMonospace(size) + } else { + return Font.monospace(size) + } } } @@ -2745,10 +2751,18 @@ func layoutTextItem( let fontLineHeight = floor(fontAscent + fontDescent) let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) let fontDescentBelowBaseline = max(0.0, -fontDescent) + // True font-height line box: shift the whole line stack down by the ascender headroom above + // the cap line (A − L) and pad the final height by the descender (D) below the last baseline, + // so a single-line item measures exactly A + D. Exact (not pixel-snapped): this is an + // intra-item line offset; crispness rides on the item's own pixel-snapped frame origin, and + // intra-item line positions may already be fractional (e.g. after a non-integral extraDescent). + // Inter-line advance is unchanged. (Named `lineBoxTopInset` to avoid colliding with the + // formula-bleed `topInset` local near the end of this function.) + let lineBoxTopInset = max(0.0, fontAscent - fontLineHeight) let baselineToNextTopSlack = max(0.0, fontLineSpacing - 4.0) var lastIndex: CFIndex = 0 - var currentLineOrigin = CGPoint() + var currentLineOrigin = CGPoint(x: 0.0, y: lineBoxTopInset) var hasAnchors = false var maxLineWidth: CGFloat = 0.0 @@ -2831,7 +2845,12 @@ func layoutTextItem( } else if let emoji = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil) let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0) - let itemSize = font.pointSize * 24.0 / 17.0 + // Size the inline emoji to the font's line height (A + D = the true + // line-box height) plus a 4pt bump at the 17pt body font (scaled + // proportionally) so it reads a touch larger than the bare line box. + // The line is NOT inflated (lineAscent stays fontLineHeight). Must match + // the run-delegate width in attributedStringForRichText (InstantPageTextItem.swift). + let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0 pendingEmoji.append(PendingV2EmojiAttachment(xOffset: xOffset, range: range, emoji: emoji, size: itemSize)) } } @@ -2897,7 +2916,7 @@ func layoutTextItem( extraDescent = max(0.0, lineDescent - baselineToNextTopSlack) // A centered attachment taller than the line bleeds below the baseline; grow the // descent so the following line isn't overlapped (mirrors V1's extraDescent handling). - // Emoji at the default 24/17 ratio stay within the line slack and contribute nothing. + // Emoji sized to the font line height (A + D) fit the line box, so they contribute nothing. for imageItem in lineImageItems { extraDescent = max(extraDescent, imageItem.frame.maxY - (baselineY + baselineToNextTopSlack)) } @@ -3088,7 +3107,9 @@ func layoutTextItem( var height: CGFloat = 0.0 if !lines.isEmpty && !(string.string == "\u{200b}" && hasAnchors) { - height = lines.last!.frame.maxY + extraDescent + // + fontDescentBelowBaseline: contain the last line's descender below its baseline, so + // (with the topInset shift) a single-line item measures exactly A + D = true font height. + height = lines.last!.frame.maxY + extraDescent + fontDescentBelowBaseline } var textWidth = boundingWidth diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index d643d7f132..523fb809c6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -228,9 +228,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode nameColors = nil } + let codeBlockBackgroundColor: UIColor let codeBlockTitleColor: UIColor let codeBlockAccentColor: UIColor - let codeBlockBackgroundColor: UIColor if !isIncoming { mainColor = messageTheme.accentTextColor if let _ = nameColors?.secondary { @@ -243,12 +243,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode if item.presentationData.theme.theme.overallDarkAppearance { codeBlockTitleColor = .white codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5) - codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25) } else { codeBlockTitleColor = mainColor codeBlockAccentColor = mainColor - codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1) } + + codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1) } else { let authorNameColor = nameColors?.main secondaryColor = nameColors?.secondary @@ -263,11 +263,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode codeBlockTitleColor = mainColor codeBlockAccentColor = mainColor - if item.presentationData.theme.theme.overallDarkAppearance { - codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65) - } else { - codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05) - } + codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1) } let _ = secondaryColor @@ -284,7 +280,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), ) let pageTheme = InstantPageTheme( type: isDark ? .dark : .light, @@ -306,8 +303,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode controlColor: messageTheme.accentControlColor, imageTintColor: nil, overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13), - separatorColor: isIncoming ? UIColor(white: 0.0, alpha: 0.25): messageTheme.accentControlColor.withMultipliedAlpha(0.25), - secondaryControlColor: messageTheme.secondaryTextColor + separatorColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3), + secondaryControlColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3) ) var hasDraft = false @@ -320,6 +317,15 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } if let attribute = item.message.richText { + #if DEBUG && false + let instantPage = InstantPage(blocks: [.thinking(.concat([ + .textCustomEmoji(fileId: 5384559872899555845, alt: "a"), + .plain("Thinking...") + ]))], media: [:], isComplete: true, rtl: false, url: "", views: nil) + #else + let instantPage = attribute.instantPage + #endif + let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent( url: "", displayUrl: "", @@ -339,7 +345,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode file: nil, story: nil, attributes: [], - instantPage: attribute.instantPage + instantPage: instantPage ))) pageWebpage = webpage @@ -352,9 +358,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode current.messageStableVersion == currentMessageStableVersion { pageLayout = current.layout } else { + #if DEBUG && false + let instantPage = InstantPage(blocks: [.thinking(.concat([ + .textCustomEmoji(fileId: 5384559872899555845, alt: "a"), + .plain("Thinking...") + ]))], media: [:], isComplete: true, rtl: false, url: "", views: nil) + #else + let instantPage = attribute.instantPage + #endif pageLayout = layoutInstantPageV2( webpage: webpage, - instantPage: attribute.instantPage, + instantPage: instantPage, userLocation: .other, boundingWidth: suggestedBoundingWidth - 2.0, horizontalInset: pageHorizontalInset, diff --git a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift index ae797425cf..01fe36a383 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift @@ -96,7 +96,8 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor) + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor) ) let pageTheme = InstantPageTheme( type: isDark ? .dark : .light, @@ -119,7 +120,7 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1), controlColor: messageTheme.accentControlColor, imageTintColor: nil, - overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13), + overlayPanelColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25), separatorColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25), secondaryControlColor: messageTheme.secondaryTextColor )