Files
simplex-chat/apps/ios/Shared/Views/Chat/ChatItem/TerminalItemView.swift
T
Evgeny @ SimpleX ChatandEvgeny Poberezkin c3c733e0d4 ios, ui, ci: terminal chat layout in the iOS app (#7898)
* plan: UI improvements to improve LLM usage via SimpleX

* core, ui: types for chat layout

* core, ui: settings for alternative terminal-like chat layout for managing LLMs or other service bots

* plan: message priority

* fixes

* query plan

* update plans

* remove silent messages plan

* rename migrations

* implement layout

* amend

* fix

* fix

* fix

* improve

* time on the right, sender name in blue

* no avatar in direct chats

* better previews

* update, fixes

* update

* time gap

* changes

* ui: terminal layout line heights, gaps and time placement

* ui: simplify terminal meta layout, correct its docs and tests

* ui: right-to-left emoji edge in terminal rows, time placement tests

* android, desktop: raise Kotlin compiler memory to 4 GB

* ui: terminal captions as follow-up text, classic event separators

* ui: terminal media edge and overlay time, plan decisions

* ui: simplify terminal row separation, correct doc line references

* ui: keep the media time inside terminal rows, restore caption conditions

* ui: terminal heading capitals at large font scales, idle live message time

* ui: rename the classic voice-with-text flag, correct the meta copy docs

* ui: smaller terminal layout diff, sender gap and meta names

* ui: terminal row as tall as the media time

* docs: remove the chat layout section from the bots readme

* docs: shorten the terminal layout section of the chat view

* core: address settings API as on master, /auto_accept reads the stored layout

* ui: smaller terminal headings

* ui: classic sender rows and avatar rule as on master

* ui: classic reply quote text colour as on master

* ios: keep the chat layout setting with other UI settings

* ui: go-to button at the end of terminal rows, no gutter in direct chats

* ui: forwarded arrow on the header line of terminal rows, above the time

* ui: forwarded arrow centred above the time line of terminal rows

* ios: terminal chat layout, chosen per app, profile and chat

* ios: event time reserve of three spaces, app layout picker as Kotlin

* ios: spec and plan of the terminal chat layout

* ios: classic message text at 87% in dark themes, as terminal rows

* ios: terminal gaps scaled by the body size, 17/16 of Kotlin values

* ios: terminal quote sender at 13.5pt, thumbnail at the item end

* ios: product docs of the terminal layout, plans updated for iOS

* ios: chat scenes rendered to PNG by a unit test in the simulator CI

* ios: snapshots rendered by the smoke test script, workflow unchanged

* ios: terminal names at 13.5 x 17/16 pt, as the gaps

* ios: Markdown views at the 87% message colour in dark themes

* ios: terminal quote thumbnail 12pt after the quote text or the bar

* ios: terminal rule-only message widened by a wider quote or header

* ios: member avatars scaled with the text size, as in the chat list

* ios: snapshot rows at their own height, as list cells, so text wraps

* ios: member avatars back at 34pt, for the snapshot before scaling

* ios: member avatars scaled with the text size, as in the chat list

* ui: terminal quote thumbnail 12dp after the quote text or the bar

* ios: classic group bubble width follows the scaled member avatar

* ci: ios-sim README describes the snapshots rendered after the smoke test

* ios: scratch, padding variants for the I7 and I8 renders, to revert

* ios: revert the scratch padding variants after their renders

* ui: contrast of the voice message button in terminal rows

* ios: scratch, spacing variants and list margins to render, to revert

* ios: revert the scratch spacing variants after their renders

* ios: snapshots at a fixed date, in UTC and the en_US locale

* ios: snapshots drawn as 8-bit sRGB without alpha

* ci: compare iOS snapshots with the references in the repository

* ci: record and check iOS snapshot references on GitHub's runner

* ios: scratch, list margins on the remaining screens, to revert

* ios: revert the scratch list margins after their measurement

* ci: snapshot scripts executable, as the other CI scripts

* ios: scratch, voice circle variants in light themes, to revert

* ios: revert the scratch voice circle variants

* ios: one 12pt padding at the edges and after avatars in terminal rows

* ios: classic group sender name on the bubble edge

* ui: one 12dp padding at the edges and after avatars in terminal rows

* ios: one heading scale in both chat layouts

* ios: snapshot references of the chat scenes

* docs: leave the headings and classic lines of the plan to ep/classic-bubbles

* ios: declare terminal layout functions inside types

* ios: keep layout constants at file scope

* ios: keep file-scope functions, move only extension members into types

* ios: snapshot tests record row heights at first layout and the appearance layout switch

* ios: appearance preview follows the chat layout setting at once

* ios: snapshot test measures first-layout rows by SwiftUI and by cells

* ios: chat rows measured at the chat width when they first appear

* ios: snapshot test checks that chat rows keep their first height after a reload

* ios, ci: references of terminal and classic chat scenes, dark message text

* ios: message text at 95% in dark themes

* ios, ci: references with message text at 95% in dark themes

* ios: media in terminal rows end at the time's right inset

* ios, ci: references with terminal media at the time's right inset

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-10-29 12:45:34 +00:00

391 lines
16 KiB
Swift

import SwiftUI
import SimpleXChat
extension EnvironmentValues {
struct TerminalLayout: EnvironmentKey {
static let defaultValue: Bool = false
}
var terminalLayout: Bool {
get { self[TerminalLayout.self] }
set { self[TerminalLayout.self] = newValue }
}
}
let chatItemTextPadding: CGFloat = 12
var terminalBlockTopGap: CGFloat { terminalGap(5.75) }
var terminalBlockBottomGap: CGFloat { terminalGap(5) }
var terminalLevelStep: CGFloat { terminalGap(4.75) }
var terminalHeadingBottomGap: CGFloat { terminalLevelStep / 2 }
var terminalSenderTopGap: CGFloat { terminalGap(6.5) }
private var terminalSenderBottomGap: CGFloat { terminalGap(5.5) }
var terminalHeaderTopGap: CGFloat { terminalGap(2.5) }
var terminalHeaderBottomGap: CGFloat { terminalGap(1.5) }
var terminalEventTopGap: CGFloat { terminalGap(8.25) }
var terminalEventBottomGap: CGFloat { terminalGap(1.5) }
var terminalFileTopGap: CGFloat { terminalGap(2) }
var terminalEmojiTopGap: CGFloat { terminalGap(4.25) }
var terminalEmojiBottomGap: CGFloat { terminalGap(1.5) }
var terminalMetaGap: CGFloat { terminalGap(6) }
var terminalNameSize: CGFloat { bodyScaled(13.5 * 17 / 16) }
let terminalHeadingRatio: CGFloat = 1.25
private let terminalLineHeightRatio: CGFloat = 22 / 16
private let terminalLineHeightContrastSlope: CGFloat = 0.1515
private let terminalGapBodySize: CGFloat = 16
func terminalGap(_ size: CGFloat) -> CGFloat {
size * UIFont.preferredFont(forTextStyle: .body).pointSize / terminalGapBodySize
}
func bodyScaled(_ points: CGFloat) -> CGFloat {
UIFontMetrics(forTextStyle: .body).scaledValue(for: points)
}
func messageTextColor(_ theme: AppTheme) -> Color {
theme.colors.onSurface.opacity(theme.colors.isLight ? 1 : 0.95)
}
func terminalBackgroundColor(_ theme: AppTheme) -> Color {
theme.wallpaper.background ?? theme.wallpaper.type.defaultBackgroundColor(theme.base, theme.colors.background)
}
struct TerminalLineBox: ViewModifier {
var spacing: CGFloat
var top: CGFloat
var bottom: CGFloat
func body(content: Content) -> some View {
content
.lineSpacing(spacing)
.padding(.top, top)
.padding(.bottom, bottom)
}
}
func terminalLineBox(_ font: UIFont, _ color: Color?, _ theme: AppTheme, capsOf bodyFont: UIFont? = nil) -> TerminalLineBox {
if let bodyFont {
let bodyLineHeight = bodyFont.pointSize * terminalLineHeightRatio
let body = proportionalExtra(bodyFont, bodyLineHeight)
return TerminalLineBox(
spacing: max(0, bodyLineHeight * font.pointSize / bodyFont.pointSize - font.lineHeight),
top: body.top + bodyFont.ascender - bodyFont.capHeight - (font.ascender - font.capHeight),
bottom: body.bottom - bodyFont.descender + font.descender
)
}
let lineHeight = terminalLineHeight(font, color, theme)
let extra = proportionalExtra(font, lineHeight)
return TerminalLineBox(spacing: max(0, lineHeight - font.lineHeight), top: extra.top, bottom: extra.bottom)
}
func terminalLineHeight(_ font: UIFont, _ color: Color?, _ theme: AppTheme) -> CGFloat {
font.pointSize * terminalLineHeightRatio * contrastLineHeightFactor(color, theme)
}
private func proportionalExtra(_ font: UIFont, _ lineHeight: CGFloat) -> (top: CGFloat, bottom: CGFloat) {
let extra = max(0, lineHeight - font.lineHeight)
let ascent = font.ascender
let descent = -font.descender
return (extra * ascent / (ascent + descent), extra * descent / (ascent + descent))
}
private func contrastLineHeightFactor(_ color: Color?, _ theme: AppTheme) -> CGFloat {
guard let color else { return 1 }
let background = UIColor(theme.colors.background)
let textContrast = lightnessContrast(UIColor(messageTextColor(theme)), background)
let weight = textContrast > 0 ? min(max(lightnessContrast(UIColor(color), background) / textContrast, 0), 1) : 1
return 1 - terminalLineHeightContrastSlope * (1 - weight)
}
private func lightnessContrast(_ color: UIColor, _ background: UIColor) -> CGFloat {
abs(lightness(composited(color, over: background)) - lightness(background))
}
private func lightness(_ color: UIColor) -> CGFloat {
let y = luminance(color)
return y > 216 / 24389 ? 116 * pow(y, 1 / 3) - 16 : y * 24389 / 27
}
private func luminance(_ color: UIColor) -> CGFloat {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
func linear(_ v: CGFloat) -> CGFloat {
v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
private func composited(_ color: UIColor, over background: UIColor) -> UIColor {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
background.getRed(&br, green: &bg, blue: &bb, alpha: &ba)
return UIColor(red: r * a + br * (1 - a), green: g * a + bg * (1 - a), blue: b * a + bb * (1 - a), alpha: 1)
}
func terminalItemShowsSender(_ item: ChatItem, _ older: ChatItem?) -> Bool {
guard item.content.hasTerminalSender else { return false }
guard let older else { return true }
return !older.content.hasTerminalSender || !sameSender(item, older)
}
private func sameSender(_ item: ChatItem, _ older: ChatItem) -> Bool {
item.channelIsSender || older.channelIsSender
? item.channelIsSender && older.channelIsSender
: item.chatDir.sameDirection(older.chatDir)
}
func terminalRowGap(_ item: ChatItem, _ older: ChatItem?, separated: Bool, largeGap: Bool, hasSenderRow: Bool) -> CGFloat {
guard let older, !separated else { return 0 }
return largeGap || hasSenderRow || !item.content.hasTerminalSender || !older.content.hasTerminalSender || (item.textAtItemTop && item.startsWithHeading)
? terminalLevelStep
: 0
}
func terminalHeadingGap(_ item: ChatItem) -> CGFloat {
!item.textAtItemTop && item.startsWithHeading ? terminalLevelStep : 0
}
private enum MessageLastLineRight: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.trailing] }
}
private enum MessageLastBlockBottom: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.bottom] }
}
struct MessageLastLine: ViewModifier {
fileprivate static let right = HorizontalAlignment(MessageLastLineRight.self)
func body(content: Content) -> some View {
content.alignmentGuide(Self.right) { $0[.trailing] }
}
}
struct MessageLastBlock: ViewModifier {
fileprivate static let bottom = VerticalAlignment(MessageLastBlockBottom.self)
func body(content: Content) -> some View {
content
.modifier(MessageLastLine())
.alignmentGuide(Self.bottom) { $0[.bottom] }
}
}
func metaCopyJoiner(_ rightToLeft: Bool) -> Text {
Text(verbatim: rightToLeft ? "\u{2060}\u{200F}" : "\u{2060}\u{200E}")
}
struct ItemWithMeta<Meta: View, Content: View>: View {
@EnvironmentObject private var theme: AppTheme
@Environment(\.terminalLayout) private var terminal
var showMeta = true
var metaReserved = false
var overlayMeta: AnyView? = nil
var endButton: AnyView? = nil
@ViewBuilder var meta: () -> Meta
@ViewBuilder var content: (_ inlineMeta: Meta?) -> Content
var body: some View {
if terminal && showMeta {
terminalBody
} else {
content(showMeta && !terminal ? meta() : nil)
}
}
@ViewBuilder private var terminalBody: some View {
if #available(iOS 16, *) {
TerminalMetaLayout(metaReserved: metaReserved, hasOverlay: overlayMeta != nil, hasEndButton: endButton != nil) {
ZStack(alignment: .topLeading) {
content(nil)
}
metaBox(meta(), theme.colors.secondary)
if let overlayMeta {
metaBox(overlayMeta, nil)
}
if let endButton {
endButton.padding(.trailing, chatItemTextPadding)
}
}
} else {
HStack(alignment: .lastTextBaseline, spacing: 0) {
content(nil).frame(maxWidth: .infinity, alignment: .leading)
if let endButton {
endButton
}
metaBox(meta(), theme.colors.secondary)
}
}
}
@ViewBuilder private func metaBox(_ meta: some View, _ lineHeightColor: Color?) -> some View {
let v = meta
.modifier(terminalLineBox(UIFont.preferredFont(forTextStyle: .caption1), lineHeightColor, theme))
.padding(.trailing, chatItemTextPadding)
if overlayMeta != nil {
v.frame(maxWidth: .infinity, maxHeight: .infinity).clipped()
} else {
v
}
}
}
@available(iOS 16, *)
private struct TerminalMetaLayout: Layout {
var metaReserved: Bool
var hasOverlay: Bool
var hasEndButton: Bool
private struct Placement {
var size: CGSize
var contentProposal: ProposedViewSize
var meta: CGRect?
var overlay: CGRect?
var endButton: CGRect?
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
placement(proposal.width, subviews).size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let p = placement(bounds.width, subviews)
subviews[0].place(at: bounds.origin, proposal: p.contentProposal)
place(subviews[1], p.meta, bounds)
if hasOverlay {
place(subviews[2], p.overlay, bounds)
}
if hasEndButton {
place(subviews[hasOverlay ? 3 : 2], p.endButton, bounds)
}
}
private func place(_ subview: LayoutSubview, _ frame: CGRect?, _ bounds: CGRect) {
if let frame {
subview.place(at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY), proposal: ProposedViewSize(frame.size))
} else {
subview.place(at: bounds.origin, proposal: .zero)
}
}
private func placement(_ proposedWidth: CGFloat?, _ subviews: Subviews) -> Placement {
let width = proposedWidth.flatMap { $0.isFinite ? $0 : nil }
let meta = subviews[1].sizeThatFits(.unspecified)
let overlay = hasOverlay ? subviews[2].sizeThatFits(.unspecified) : nil
let endButton = hasEndButton ? subviews[hasOverlay ? 3 : 2].sizeThatFits(.unspecified) : nil
let reservedWidth = max(!metaReserved && overlay == nil ? meta.width : 0, endButton?.width ?? 0)
let contentProposal = ProposedViewSize(width: width.map { max(0, $0 - reservedWidth) }, height: nil)
let content = subviews[0].dimensions(in: contentProposal)
let rowWidth = width ?? max(content.width + reservedWidth, meta.width)
let gap = terminalMetaGap
let metaBelow = content[explicit: MessageLastLine.right].map { $0 + meta.width > rowWidth } ?? false
let metaBaseline = subviews[1].dimensions(in: .unspecified)[explicit: .firstTextBaseline]
?? (meta.height + UIFont.preferredFont(forTextStyle: .caption1).capHeight) / 2
let textBaseline = overlay == nil ? content[explicit: .lastTextBaseline] : nil
let metaTop = max(0, metaBelow
? content.height + gap
: textBaseline.map { $0 - metaBaseline } ?? content.height - gap - meta.height
)
let blockGap = content[explicit: MessageLastBlock.bottom].map { abs($0 - content.height) < 0.5 } == true ? terminalBlockBottomGap : 0
let overlayFrame: CGRect? = if let overlay, content.width + meta.width > rowWidth {
CGRect(x: content.width - overlay.width, y: max(0, content.height - gap - overlay.height), width: overlay.width, height: overlay.height)
} else {
nil
}
let endButtonFrame: CGRect? = endButton.map {
CGRect(x: rowWidth - $0.width, y: max(0, ((overlayFrame == nil ? metaTop : content.height) - $0.height) / 2), width: $0.width, height: $0.height)
}
return Placement(
size: CGSize(
width: rowWidth,
height: max(content.height + blockGap, metaTop + meta.height, overlayFrame?.height ?? 0, endButtonFrame?.maxY ?? 0)
),
contentProposal: contentProposal,
meta: overlayFrame == nil ? CGRect(x: rowWidth - meta.width, y: metaTop, width: meta.width, height: meta.height) : nil,
overlay: overlayFrame,
endButton: endButtonFrame
)
}
}
struct ChatEventWithMeta<Content: View>: View {
@EnvironmentObject private var theme: AppTheme
@Environment(\.layoutDirection) private var layoutDirection
var chatItem: ChatItem
@ViewBuilder var content: (_ ts: Text) -> Content
var body: some View {
ItemWithMeta(metaReserved: true) {
eventMetaText(color: theme.colors.secondary)
} content: { inlineMeta in
content(
inlineMeta == nil
? (metaCopyJoiner(layoutDirection == .rightToLeft) + Text(verbatim: "\u{00A0}\u{00A0}") + eventMetaText(colorMode: .transparent, unbreakable: true)).fontWeight(.regular)
: chatItem.timestampText
)
}
}
private func eventMetaText(color: Color = .clear, colorMode: MetaColorMode = .normal, unbreakable: Bool = false) -> Text {
ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, color: color, colorMode: colorMode, showStatus: false, showEdited: false, showViaProxy: false, showTimesamp: true, unbreakable: unbreakable)
}
}
var terminalEventPadding: EdgeInsets {
EdgeInsets(top: terminalEventTopGap, leading: chatItemTextPadding, bottom: terminalEventBottomGap, trailing: chatItemTextPadding)
}
struct ChatItemSender {
var name: Text
var badge: LocalBadge?
var role: String?
var own: Bool
var image: AnyView?
}
struct TerminalChatItemRow<Content: View>: View {
@EnvironmentObject private var theme: AppTheme
var sender: ChatItemSender?
var gutter: CGFloat
var topGap: CGFloat
var senderTopGap: CGFloat = terminalSenderTopGap
@ViewBuilder var content: () -> Content
var body: some View {
HStack(alignment: .top, spacing: 0) {
if gutter > 0 {
ZStack(alignment: .topLeading) {
Color.clear.frame(width: gutter, height: 0)
sender?.image?.padding(.leading, chatItemTextPadding)
}
}
VStack(alignment: .leading, spacing: 0) {
if let sender {
senderNameAndRole(sender)
.padding(.leading, chatItemTextPadding)
.padding(.bottom, terminalSenderBottomGap)
}
content()
}
}
.padding(.top, topGap + (sender == nil ? 0 : senderTopGap))
}
private func senderNameAndRole(_ sender: ChatItemSender) -> some View {
let size = terminalNameSize
return HStack(alignment: .firstTextBaseline, spacing: 0) {
NameWithBadge(sender.name.font(.system(size: size)).foregroundColor(sender.own ? theme.colors.primary : theme.colors.secondary), sender.badge, .footnote)
.lineLimit(1)
if let role = sender.role {
Text(role)
.font(.system(size: size, weight: .medium))
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
.padding(.leading, 15)
.layoutPriority(1)
}
}
.modifier(terminalLineBox(UIFont.systemFont(ofSize: size), nil, theme))
}
}