Skip to content
1 change: 1 addition & 0 deletions Maccy/Extensions/Defaults.Keys+Names.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ extension Defaults.Keys {
static let searchVisibility = Key<SearchVisibility>("searchVisibility", default: .always)
static let showSpecialSymbols = Key<Bool>("showSpecialSymbols", default: true)
static let showTitle = Key<Bool>("showTitle", default: true)
static let titleLines = Key<Int>("titleLines", default: 1)
static let size = Key<Int>("historySize", default: 200)
static let sortBy = Key<Sorter.By>("sortBy", default: .lastCopiedAt)
static let suppressClearAlert = Key<Bool>("suppressClearAlert", default: false)
Expand Down
4 changes: 2 additions & 2 deletions Maccy/Maccy.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>
50 changes: 33 additions & 17 deletions Maccy/Models/HistoryItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,37 @@ class HistoryItem {
}
}

func formatForDisplay(_ raw: String) -> String {
let titleLines = Defaults[.titleLines]

if titleLines > 1 {
let lines = raw.components(separatedBy: "\n")
let firstLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.prefix(titleLines)
return firstLines.map { formatLine($0) }.joined(separator: "\n")
}

if Defaults[.showSpecialSymbols] {
return formatLine(raw.replacingOccurrences(of: "\n", with: "⏎"))
}

return raw.trimmingCharacters(in: .whitespacesAndNewlines)
}

func formatLine(_ line: String) -> String {
if Defaults[.showSpecialSymbols] {
var result = line
if let range = result.range(of: "^ +", options: .regularExpression) {
result = result.replacingOccurrences(of: " ", with: "·", range: range)
}
if let range = result.range(of: " +$", options: .regularExpression) {
result = result.replacingOccurrences(of: " ", with: "·", range: range)
}
result = result.replacingOccurrences(of: "\t", with: "⇥")
return result
}
return line.trimmingCharacters(in: .whitespaces)
}

func generateTitle() -> String {
guard image == nil else {
Task {
Expand All @@ -94,23 +125,8 @@ class HistoryItem {
}

// 1k characters is trade-off for performance
var title = previewableText.shortened(to: 1_000)

if Defaults[.showSpecialSymbols] {
if let range = title.range(of: "^ +", options: .regularExpression) {
title = title.replacingOccurrences(of: " ", with: "·", range: range)
}
if let range = title.range(of: " +$", options: .regularExpression) {
title = title.replacingOccurrences(of: " ", with: "·", range: range)
}
title = title
.replacingOccurrences(of: "\n", with: "⏎")
.replacingOccurrences(of: "\t", with: "⇥")
} else {
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
}

return title
let raw = previewableText.shortened(to: 1_000)
return formatForDisplay(raw)
}

var previewableText: String {
Expand Down
31 changes: 29 additions & 2 deletions Maccy/Observables/HistoryItemDecorator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,34 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility {
}

var hasImage: Bool { item.image != nil }
var hasFileURLs: Bool { !item.fileURLs.isEmpty }

var previewImageGenerationTask: Task<(), Error>?
var thumbnailImageGenerationTask: Task<(), Error>?
var previewImage: NSImage?
var thumbnailImage: NSImage?
var applicationImage: ApplicationImage

// 10k characters seems to be more than enough on large displays
var text: String { item.previewableText.shortened(to: 10_000) }
// 10k characters seems to be more than enough on large displays.
// Cached as stored property to avoid recomputing previewableText (which
// may parse RTF/HTML) on every SwiftUI access.
let text: String

/// First `maxTitleLines` non-blank lines, pre-split to avoid re-splitting on every access.
private let firstLines: [String]

/// `text` truncated to `titleLines` with `showSpecialSymbols` applied.
/// Computed so that changing `titleLines` in preferences affects all items immediately.
var displayText: String {
let lines = Defaults[.titleLines]
if lines > 1 {
return firstLines.prefix(lines).map { item.formatLine($0) }.joined(separator: "\n")
}
if Defaults[.showSpecialSymbols] {
return item.formatLine(text.replacingOccurrences(of: "\n", with: "⏎"))
}
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}

var isPinned: Bool { item.pin != nil }
var isUnpinned: Bool { item.pin == nil }
Expand All @@ -68,6 +87,14 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility {
self.title = item.title
self.applicationImage = ApplicationImageCache.shared.getImage(item: item)

// Precompute text to avoid repeated previewableText (RTF/HTML parsing) on SwiftUI reads.
let rawText = item.previewableText.shortened(to: 10_000)
self.text = rawText
self.firstLines = Array(rawText
.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.prefix(5)) // max titleLines from stepper range

synchronizeItemPin()
synchronizeItemTitle()
}
Expand Down
18 changes: 17 additions & 1 deletion Maccy/Settings/AppearanceSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct AppearanceSettingsPane: View {
@Default(.popupScreen) private var popupScreen
@Default(.pinTo) private var pinTo
@Default(.imageMaxHeight) private var imageHeight
@Default(.titleLines) private var titleLines
@Default(.previewDelay) private var previewDelay
@Default(.highlightMatch) private var highlightMatch
@Default(.menuIcon) private var menuIcon
Expand All @@ -26,7 +27,12 @@ struct AppearanceSettingsPane: View {
formatter.maximum = 200
return formatter
}()

private let titleLinesFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.minimum = 1
formatter.maximum = 5
return formatter
}()
private let numberOfItemsFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.minimum = 0
Expand Down Expand Up @@ -100,6 +106,16 @@ struct AppearanceSettingsPane: View {
}
}

Settings.Section(label: { Text("TitleLines", tableName: "AppearanceSettings") }) {
HStack {
TextField("", value: $titleLines, formatter: titleLinesFormatter)
.frame(width: 120)
.help(Text("TitleLinesTooltip", tableName: "AppearanceSettings"))
Stepper("", value: $titleLines, in: 1...5)
.labelsHidden()
}
}

Settings.Section(label: { Text("PreviewDelay", tableName: "AppearanceSettings") }) {
HStack {
TextField("", value: $previewDelay, formatter: previewDelayFormatter)
Expand Down
2 changes: 2 additions & 0 deletions Maccy/Settings/en.lproj/AppearanceSettings.strings
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"PinToTooltip" = "Change the location of pinned items.\nDefault: Top.";
"ImageHeight" = "Image height:";
"ImageHeightTooltip" = "Maximum image preview height.\nDefault: 40.\nHint: Set to 16 to look like text items.";
"TitleLines" = "Title lines:";
"TitleLinesTooltip" = "Number of lines displayed per history item.\nDefault: 1.\nHint: Increase to see more text in each item.";
"PreviewDelay" = "Preview delay:";
"PreviewDelayTooltip" = "Delay in milliseconds until a preview popup is shown.\nDefault: 1500.";
"HighlightMatches" = "Highlight matches:";
Expand Down
2 changes: 2 additions & 0 deletions Maccy/Views/HistoryItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ struct HistoryItemView: View {
image: item.thumbnailImage,
accessoryImage: item.thumbnailImage != nil ? nil : ColorImage.from(item.title),
attributedTitle: item.attributedTitle,
rawTitle: item.displayText,
shortcuts: item.shortcuts,
isSelected: item.isSelected,
selectionIndex: visualIndex,
selectionAppearance: selectionAppearance
) {
Text(verbatim: item.title)
}
.padding(.vertical, 0.5)
.onAppear {
item.ensureThumbnailImage()
}
Expand Down
102 changes: 89 additions & 13 deletions Maccy/Views/ListItemTitleView.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,99 @@
import Defaults
import SwiftUI

// MARK: - Truncation helpers

/// Font matching SwiftUI's default `.body` on macOS.
private let bodyFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)

/// Truncate a single line by inserting " ... " in the middle if it exceeds maxWidth.
/// Uses NSAttributedString for accurate pixel-width measurement.
func truncateLine(_ text: String, maxWidth: CGFloat) -> String {
let ellipsis = " ... "
let attrs: [NSAttributedString.Key: Any] = [.font: bodyFont]

func width(of s: String) -> CGFloat {
NSAttributedString(string: s, attributes: attrs).size().width
}

guard width(of: text) > maxWidth else { return text }

let chars = Array(text)

// Binary search the longest prefix that still allows suffix + ellipsis to fit.
var lo = 0
var hi = chars.count / 2
while lo < hi {
let mid = (lo + hi + 1) / 2
let suffixStart = chars.count - mid
let candidate = String(chars[0..<mid]) + ellipsis + String(chars[suffixStart...])
if width(of: candidate) <= maxWidth {
lo = mid
} else {
hi = mid - 1
}
}

if lo == 0 {
// Nothing fits with ellipsis; just cut to width.
var result = ""
for ch in chars {
guard width(of: result + String(ch)) <= maxWidth else { break }
result += String(ch)
}
return result
}

let suffixStart = chars.count - lo
return String(chars[0..<lo]) + ellipsis + String(chars[suffixStart...])
}

/// Truncate multi-line text: split by newlines, take first maxLines lines,
/// truncate each long line individually, join with newlines.
func truncateText(_ raw: String, maxWidth: CGFloat, maxLines: Int) -> String {
let lines = raw.components(separatedBy: .newlines)
let capped = lines.prefix(maxLines)
return capped.map { truncateLine($0, maxWidth: maxWidth) }.joined(separator: "\n")
}

// MARK: - ListItemTitleView

struct ListItemTitleView<Title: View>: View {
var attributedTitle: AttributedString?
var rawTitle: String?
@ViewBuilder var title: () -> Title
@Default(.titleLines) private var titleLines
@Default(.windowSize) private var windowSize
@Default(.showApplicationIcons) private var showIcons

/// Estimated text area width: window width minus padding for icons, spacers,
/// shortcuts, and trailing margin.
private var estimatedTextWidth: CGFloat {
let base = windowSize.width
let iconArea: CGFloat = showIcons ? 30 : 15
let shortcutsArea: CGFloat = 40
let margin: CGFloat = 20
return max(base - iconArea - shortcutsArea - margin, 80)
}

var body: some View {
if let attributedTitle {
Text(attributedTitle)
.accessibilityIdentifier("copy-history-item")
.lineLimit(1)
.truncationMode(.middle)
} else {
title()
.accessibilityIdentifier("copy-history-item")
.lineLimit(1)
.truncationMode(.middle)
// Workaround for macOS 26 to avoid flipped text
// https://github.com/p0deje/Maccy/issues/1113
.drawingGroup()
Group {
if let attributedTitle {
Text(attributedTitle)
.accessibilityIdentifier("copy-history-item")
.lineLimit(titleLines)
.truncationMode(.middle)
} else if let rawTitle {
Text(truncateText(rawTitle, maxWidth: estimatedTextWidth, maxLines: titleLines))
.accessibilityIdentifier("copy-history-item")
.lineLimit(titleLines)
} else {
title()
.accessibilityIdentifier("copy-history-item")
.lineLimit(titleLines)
.truncationMode(.middle)
.drawingGroup()
}
}
}
}
23 changes: 18 additions & 5 deletions Maccy/Views/ListItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct ListItemView<Title: View, ID: Hashable>: View {
var image: NSImage?
var accessoryImage: NSImage?
var attributedTitle: AttributedString?
var rawTitle: String?
var shortcuts: [KeyShortcut]
var isSelected: Bool
var selectionIndex: Int?
Expand Down Expand Up @@ -74,7 +75,7 @@ struct ListItemView<Title: View, ID: Hashable>: View {
.padding(.trailing, 5)
.padding(.vertical, 5)
} else {
ListItemTitleView(attributedTitle: attributedTitle, title: title)
ListItemTitleView(attributedTitle: attributedTitle, rawTitle: rawTitle, title: title)
.padding(.trailing, 5)
}

Expand Down Expand Up @@ -109,10 +110,22 @@ struct ListItemView<Title: View, ID: Hashable>: View {
.frame(minHeight: Popup.itemHeight)
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(isSelected ? Color.white : .primary)
// macOS 26 broke hovering if no background is present.
// The slight opcaity white background is a workaround
.background(isSelected ? Color.accentColor.opacity(0.8) : .white.opacity(0.001))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.background(
RoundedRectangle(cornerRadius: Popup.cornerRadius)
.fill(
isSelected
? Color.accentColor.opacity(0.12)
: Color.primary.opacity(0.03)
)
)
.overlay(
RoundedRectangle(cornerRadius: Popup.cornerRadius)
.strokeBorder(
isSelected ? Color.accentColor : Color.primary.opacity(0.15),
lineWidth: 1
)
)
.clipShape(selectionAppearance.rect(cornerRadius: Popup.cornerRadius))
.hoverSelectionId(selectionId)
.help(help ?? "")
Expand Down
Loading