Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Relay/RelayApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ struct RelayApp: App {
QuickSwitchCommand(appActions: appActions)
SidebarCommands()
InspectorCommands()
TextSizeCommands()
CommandGroup(before: .appTermination) {
Button("Clear Cache…") {
showClearCacheConfirmation = true
Expand Down Expand Up @@ -449,6 +450,35 @@ struct QuickSwitchCommand: Commands {
}
}

// MARK: - Text Size Commands

/// Adds message text-zoom items to the View menu: Increase Text Size (⌘+),
/// Reset Text Size (⌥⌘0), and Decrease Text Size (⌘−).
///
/// Each adjusts ``MessageTextScale``, which rescales the conversation text,
/// mention pills, and the compose field together.
struct TextSizeCommands: Commands {
var body: some Commands {
CommandGroup(after: .toolbar) {
Divider()
Button("Increase Text Size") {
MessageTextScale.increase()
}
.keyboardShortcut("+", modifiers: .command)

Button("Reset Text Size") {
MessageTextScale.reset()
}
.keyboardShortcut("0", modifiers: [.option, .command])

Button("Decrease Text Size") {
MessageTextScale.decrease()
}
.keyboardShortcut("-", modifiers: .command)
}
}
}

// MARK: - Notification Delegate

/// Handles notification presentation and user interactions for local notifications.
Expand Down
16 changes: 8 additions & 8 deletions Relay/Utilities/MatrixHTMLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ extension NSAttributedString {

// 4. Bridge to NSAttributedString and resolve InlinePresentationIntent
// into concrete AppKit fonts and decorations.
let baseFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let baseFont = MessageTextScale.baseFont
let result = NSMutableAttributedString(attributedString: NSAttributedString(source))
let fullRange = NSRange(location: 0, length: result.length)

Expand Down Expand Up @@ -360,7 +360,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length

case "blockquote":
blockquoteDepth += 1
let baseFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let baseFont = MessageTextScale.baseFont
let barString = "\u{2502} "
let barWidth = (barString as NSString)
.size(withAttributes: [.font: baseFont]).width
Expand Down Expand Up @@ -392,7 +392,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length
let separator = NSAttributedString(
string: "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}",
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
.font: MessageTextScale.baseFont,
.foregroundColor: NSColor.separatorColor
]
)
Expand All @@ -417,7 +417,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length
marker = "\(bullets[min(depth - 1, bullets.count - 1)]) "
}
ensureNewline(in: result)
let baseFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let baseFont = MessageTextScale.baseFont
let markerWidth = (marker as NSString)
.size(withAttributes: [.font: baseFont]).width
let basePad: CGFloat = 6.0
Expand Down Expand Up @@ -523,7 +523,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length
}
}
// Apply paragraph style for blockquote wrapping.
let baseFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let baseFont = MessageTextScale.baseFont
let barWidth = ("\u{2502} " as NSString)
.size(withAttributes: [.font: baseFont]).width
let style = NSMutableParagraphStyle()
Expand Down Expand Up @@ -571,7 +571,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length
let level = Int(String(tag.last!))!
let scales: [CGFloat] = [1.5, 1.35, 1.2, 1.1, 1.05, 1.0]
let scale = scales[min(level - 1, scales.count - 1)]
let headingSize = NSFont.systemFontSize * scale
let headingSize = MessageTextScale.baseFontSize * scale
let headingFont = NSFont.boldSystemFont(ofSize: headingSize)
result.addAttribute(.font, value: headingFont, range: range)
let style = NSMutableParagraphStyle()
Expand Down Expand Up @@ -599,7 +599,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length
// Re-derive the paragraph style for this list depth.
let depth = listStack.count
if depth > 0 {
let baseFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let baseFont = MessageTextScale.baseFont
// Use a placeholder marker to measure width consistently.
let sampleMarker = listStack[depth - 1].ordered ? "0. " : "\u{2022} "
let markerWidth = (sampleMarker as NSString)
Expand Down Expand Up @@ -664,7 +664,7 @@ private struct MatrixHTMLParser { // swiftlint:disable:this type_body_length

// swiftlint:disable:next cyclomatic_complexity function_body_length
private func buildAttributes(from style: Style) -> [NSAttributedString.Key: Any] {
let baseSize = NSFont.systemFontSize
let baseSize = MessageTextScale.baseFontSize
var attrs: [NSAttributedString.Key: Any] = [:]

// Font
Expand Down
9 changes: 7 additions & 2 deletions Relay/Utilities/MentionPillView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ struct MentionPillView: View {
/// Set to `false` for keyword highlight pills.
var showAtPrefix: Bool = true

/// Point size of the surrounding message text. The pill's own text renders
/// one point smaller than this, so the pill scales with the timeline's
/// text-zoom level and the rendered bitmap always matches the attachment
/// bounds rather than being upscaled (which reads as a stretched capsule).
var fontSize: CGFloat = NSFont.systemFontSize

private var pillText: String {
if !showAtPrefix { return displayName }
return displayName.hasPrefix("@") ? displayName : "@\(displayName)"
Expand Down Expand Up @@ -91,8 +97,7 @@ struct MentionPillView: View {

var body: some View {
Text(pillText)
.font(.callout)
.bold()
.font(.system(size: fontSize - 1, weight: .bold))
.foregroundStyle(textColor)
.padding(.horizontal, 4)
.background(backgroundColor, in: .capsule)
Expand Down
120 changes: 120 additions & 0 deletions Relay/Utilities/MessageTextScale.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2026 Link Dupont
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import AppKit
import SwiftUI

/// The user-adjustable zoom level for conversation and compose text.
///
/// Message bodies, mention pills, and the compose field derive their point size
/// from ``baseFont`` rather than `NSFont.systemFontSize` directly, so the whole
/// reading and typing area scales together when the View ▸ Make Text
/// Bigger/Smaller commands change the scale. The factor is persisted in
/// `UserDefaults`; ``increase()``/``decrease()``/``reset()`` update it, drop the
/// now-stale parse caches, and broadcast ``didChangeNotification`` so the
/// timeline can re-measure its rows and the compose bar can re-apply its font.
enum MessageTextScale {
/// `UserDefaults` key holding the scale factor as a `Double`.
nonisolated static let userDefaultsKey = "timeline.textScale"

/// The `UserDefaults` suite the scale is persisted to and read from.
/// Defaults to `.standard`. A fully-serialized test suite that exercises
/// ``increase()``/``decrease()``/``reset()`` may override this to a
/// private, throwaway suite so its writes can't race with any other test
/// (or the app's own persisted value) reading `.standard` concurrently —
/// `UserDefaults` itself is thread-safe, but swapping *which store*
/// every reader/writer here uses is not.
nonisolated(unsafe) static var userDefaults: UserDefaults = .standard

/// Posted after the scale changes and the parse caches are cleared.
static let didChangeNotification = Notification.Name("relay.messageTextScaleDidChange")

/// Neutral scale — message text renders at the system font size.
nonisolated static let defaultScale: CGFloat = 1
nonisolated static let minScale: CGFloat = 0.8
nonisolated static let maxScale: CGFloat = 2.4

/// Additive step applied by ``increase()`` / ``decrease()``.
private static let step: CGFloat = 0.1

/// The current scale factor (1.0 = system size), clamped to a sane range.
///
/// `nonisolated` so message parsing can read it off the main actor; the
/// backing `UserDefaults` read is itself thread-safe.
nonisolated static var scale: CGFloat {
let stored = userDefaults.object(forKey: userDefaultsKey) as? Double
let value = stored.map { CGFloat($0) } ?? defaultScale
return clamp(value)
}

/// Clamps a raw scale value to `[minScale, maxScale]`. Shared by ``scale``
/// and ``ScaledChromeFont`` so both apply the same bound even though the
/// latter reads the persisted value through `@AppStorage` (for SwiftUI
/// reactivity) rather than through ``scale`` itself.
nonisolated static func clamp(_ value: CGFloat) -> CGFloat {
min(max(value, minScale), maxScale)
}

/// The base message/compose font point size at the current scale.
nonisolated static var baseFontSize: CGFloat {
NSFont.systemFontSize * scale
}

/// The base message/compose font at the current scale.
nonisolated static var baseFont: NSFont {
NSFont.systemFont(ofSize: baseFontSize)
}

@MainActor static func increase() { apply(scale + step) }
@MainActor static func decrease() { apply(scale - step) }
@MainActor static func reset() { apply(defaultScale) }

/// Persists `newValue` (clamped) and notifies observers. A no-op when the
/// clamped value is unchanged, so hitting the limit doesn't churn the
/// timeline. Cache invalidation is left to the observer that owns the
/// affected cache (``TimelineTableViewController``) rather than done here,
/// so this Utilities-layer type doesn't need to know about a specific
/// Views-layer cache.
@MainActor private static func apply(_ newValue: CGFloat) {
let clamped = clamp(newValue)
guard abs(clamped - scale) > 0.001 else { return }
userDefaults.set(Double(clamped), forKey: userDefaultsKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
}

// MARK: - Scaled Chrome Font

private struct ScaledChromeFont: ViewModifier {
let textStyle: NSFont.TextStyle
let weight: Font.Weight

@AppStorage(MessageTextScale.userDefaultsKey) private var scale = Double(MessageTextScale.defaultScale)

func body(content: Content) -> some View {
let base = NSFont.preferredFont(forTextStyle: textStyle).pointSize
content.font(.system(size: base * MessageTextScale.clamp(CGFloat(scale)), weight: weight))
}
}

extension View {
/// Applies a system font for `textStyle` scaled by the current message
/// text-zoom level — for chrome (sender names, timestamps) that should track
/// the conversation text. Reading the scale through `@AppStorage` re-renders
/// on zoom regardless of any `Equatable` view optimization, and the detached
/// row-measurement host reads the same value so heights stay correct.
func scaledChromeFont(_ textStyle: NSFont.TextStyle, weight: Font.Weight = .regular) -> some View {
modifier(ScaledChromeFont(textStyle: textStyle, weight: weight))
}
}
115 changes: 100 additions & 15 deletions Relay/Utilities/ParseCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,30 @@ import Foundation
/// A simple LRU cache for expensive parse results (HTML, Markdown, URL detection).
///
/// Thread-safe via `NSLock`. Designed for main-thread hot paths where the same
/// content is re-parsed on every SwiftUI body evaluation.
/// content is re-parsed on every SwiftUI body evaluation. Backed by a
/// dictionary of doubly-linked-list nodes so every operation (get, set,
/// recency promotion, eviction) is O(1) rather than scanning a recency array.
final class ParseCache<Key: Hashable, Value>: @unchecked Sendable {
/// `prev` is `weak` so the list's only strong ownership chain runs
/// `head -> next -> ... -> tail`; dropping a node from `nodes` and
/// unlinking it from that chain lets ARC deallocate it immediately,
/// instead of the two directions retaining each other forever.
private final class Node {
let key: Key
var value: Value
weak var prev: Node?
var next: Node?

init(key: Key, value: Value) {
self.key = key
self.value = value
}
}

private let capacity: Int
private var storage: [Key: Value] = [:]
private var order: [Key] = []
private var nodes: [Key: Node] = [:]
private var head: Node?
private var tail: Node?
private let lock = NSLock()

init(capacity: Int) {
Expand All @@ -31,11 +50,9 @@ final class ParseCache<Key: Hashable, Value>: @unchecked Sendable {
/// Returns the cached value for `key`, or computes and caches it using `compute`.
func value(forKey key: Key, compute: () -> Value) -> Value {
lock.lock()
if let cached = storage[key] {
// Move to end (most recently used).
if let idx = order.firstIndex(of: key) {
order.append(order.remove(at: idx))
}
if let node = nodes[key] {
moveToFront(node)
let cached = node.value
lock.unlock()
return cached
}
Expand All @@ -44,14 +61,82 @@ final class ParseCache<Key: Hashable, Value>: @unchecked Sendable {
let result = compute()

lock.lock()
storage[key] = result
order.append(key)
if order.count > capacity {
let evicted = order.removeFirst()
storage.removeValue(forKey: evicted)
defer { lock.unlock() }
// A concurrent caller may have inserted this key while `compute()`
// ran unlocked; keep the existing value (first writer wins) rather
// than inserting a second node for the same key.
if let existing = nodes[key] {
moveToFront(existing)
return existing.value
}
lock.unlock()

insert(key: key, value: result)
return result
}

/// Returns the cached value for `key` without computing or promoting it —
/// an O(1) read safe to call from hot paths such as SwiftUI `body`. Recency
/// is updated only by ``set(_:forKey:)``, which suffices for caches that
/// write on resolution. Returns `nil` on a miss.
func peek(_ key: Key) -> Value? {
lock.lock()
defer { lock.unlock() }
return nodes[key]?.value
}

/// Removes every cached entry. Used when a global input the cached values
/// depend on (e.g. the message text-zoom level) changes and every previously
/// computed value is stale.
func removeAll() {
lock.lock()
defer { lock.unlock() }
nodes.removeAll()
head = nil
tail = nil
}

/// Stores `value` for `key`, evicting the least-recently-used entry when the
/// cache exceeds its capacity.
func set(_ value: Value, forKey key: Key) {
lock.lock()
defer { lock.unlock() }
if let node = nodes[key] {
node.value = value
moveToFront(node)
} else {
insert(key: key, value: value)
}
}

// MARK: - Linked-list bookkeeping (call only while holding `lock`)

private func insert(key: Key, value: Value) {
let node = Node(key: key, value: value)
nodes[key] = node
node.next = head
head?.prev = node
head = node
if tail == nil { tail = node }
evictIfNeeded()
}

private func moveToFront(_ node: Node) {
guard head !== node else { return }
node.prev?.next = node.next
node.next?.prev = node.prev
if tail === node { tail = node.prev }
node.prev = nil
node.next = head
head?.prev = node
head = node
if tail == nil { tail = node }
}

private func evictIfNeeded() {
while nodes.count > capacity, let evicted = tail {
nodes.removeValue(forKey: evicted.key)
tail = evicted.prev
tail?.next = nil
if head === evicted { head = nil }
}
}
}
Loading
Loading