mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-13 07:07:46 +00:00
* 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>
391 lines
16 KiB
Swift
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))
|
|
}
|
|
}
|