Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
634f3d1
Don't clear search on pin toggle
weisJ Sep 5, 2025
2239538
Extract computing height of views into convenience modifier
weisJ Sep 14, 2025
5ee53ac
Refactor HistoryListView
weisJ Sep 14, 2025
8662db7
Introduce concept of selection being more than one item
weisJ Sep 14, 2025
b2b4d65
Handle multiple selection with keyboard
weisJ Sep 14, 2025
1747db6
Handle multiple selection with mouse
weisJ Sep 14, 2025
cce33de
Connect backgrounds of neighboring selected items
weisJ Sep 14, 2025
20a9ac3
Simplify height computation
weisJ Sep 14, 2025
ea70c46
Implement paste stack behaviour
weisJ Sep 14, 2025
7fc94db
Show paste stack in UI
weisJ Sep 14, 2025
4413816
Extract hover selection behaviour into modifier
weisJ Sep 14, 2025
e68ec69
Show order of selected items and paste stack count in UI
weisJ Sep 14, 2025
ab05af7
Only set numbered pins shortcut for single digit numbers
weisJ Sep 14, 2025
8e3e47c
Extract navigation related things into own class
weisJ Sep 14, 2025
2c05e49
Create own view for the application image
weisJ Sep 15, 2025
5ad2110
Improve paste stack background in dark mode
weisJ Sep 15, 2025
52303d9
Silence swiftlint
weisJ Nov 9, 2025
f60b926
Ensure selection order when bulk selecting
weisJ Dec 26, 2025
2397fae
Avoid intermediate arrays
weisJ Sep 15, 2025
d7d80b9
Implement slideout logic
weisJ Jan 19, 2026
d42cc07
Remove old preview logic and implement new preview
weisJ Jan 19, 2026
323b3b3
Extract some actions from KeyHandlingView into AppState
weisJ Jan 19, 2026
d6b07e3
Implement toolbar
weisJ Jan 19, 2026
01f62d7
Adjust auto resize behaviour if preview is open
weisJ Jan 19, 2026
eed2b4b
Add shortcut for opening preview
weisJ Jan 19, 2026
cdc6eff
Adjust clear button style of search box
weisJ Jan 19, 2026
d1ac3a6
Fix swiftlint errors
weisJ Jan 19, 2026
5d8c74a
Split pin and unpin text
weisJ Jan 19, 2026
bc24bf8
Adjust wording in settings for preview toggle shortcut
weisJ Jan 19, 2026
744765e
Slideout automatically after preview delay
p0deje Jan 23, 2026
c21258a
Ensure footer is at the bottom when previewing image
p0deje Jan 23, 2026
a110947
Change preivew width when esizing Maccy
p0deje Jan 23, 2026
5fd187c
Simplify preview toolbar buttons
p0deje Jan 24, 2026
a166eee
Hide preview when hovering over footer items
p0deje Jan 26, 2026
e4197a7
Match clear search / close preview button colors
p0deje Jan 26, 2026
b676c55
Restore old preview toggle button
weisJ Jan 29, 2026
6cf02f8
Asynchronously load preview image
weisJ Jan 29, 2026
b88d999
Revert "Change preivew width when esizing Maccy"
weisJ Jan 29, 2026
4fdfdaa
Avoid auto opening preview when resizing window
weisJ Jan 29, 2026
3edfcd3
Correctly compute window save location
weisJ Jan 29, 2026
941ba1d
Better handling of window resizing by user
weisJ Jan 29, 2026
a3afd92
Simplify layout constraints for SlideoutView
weisJ Jan 29, 2026
22e1f17
Effectively disable paste stack feature
weisJ Jan 29, 2026
c586be5
Persist preview width between sessions
weisJ Jan 29, 2026
0060517
Disable remaining code paths for multiselection
weisJ Jan 29, 2026
3942f74
Fix swiftlint errors
weisJ Jan 29, 2026
0a46067
Add translations for slideout preview
p0deje Jan 27, 2026
cbf45c2
Switch to Control-Space to open preview
p0deje Jan 28, 2026
ee69cd0
Move toolbar to the preview view
p0deje Jan 30, 2026
eba3c93
Fix wrong tooltip on delete button
p0deje Jan 30, 2026
0d9ee99
Fix incorrect hr/uz translations
p0deje Jan 30, 2026
3340341
Fix auto-opening
p0deje Feb 3, 2026
612c0b7
Round slideout to avoid 1-2 px movement to the left
p0deje Feb 3, 2026
12968ed
Reset preview state when re-opening popup
p0deje Feb 4, 2026
a8218ef
Fix invalid key in preview tooltip
p0deje Feb 5, 2026
ae22d51
Fix missing unpin key translations
p0deje Feb 5, 2026
83b5eb5
Fix swiftlint error
p0deje Feb 5, 2026
4935fbd
Fix window height with small number of items
p0deje Feb 5, 2026
3a7a608
Reset search after toggling pin
p0deje Feb 5, 2026
fc9094b
Reset search after deleting item
p0deje Feb 5, 2026
11292e7
Fix bug when only 1 item cannot be delete
p0deje Feb 5, 2026
15431f4
Highlight next found item after deletion
p0deje Feb 5, 2026
6924c0c
Tidy up rounding window size including preview
p0deje Feb 6, 2026
6e20a29
Cleanup unnecessary swiftlint comments
p0deje Feb 8, 2026
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
73 changes: 73 additions & 0 deletions Maccy.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion Maccy/Clipboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,19 @@ class Clipboard {

@objc
@MainActor
func checkForChangesInPasteboard() {
func checkForChangesInPasteboard() { // swiftlint:disable:this cyclomatic_complexity
guard pasteboard.changeCount != changeCount else {
return
}

changeCount = pasteboard.changeCount

if pasteboard.pasteboardItems?.contains(where: { $0.types.contains(.fromMaccy) }) != true {
// External copy occurred. Stop the current paste stack.
// Maybe queue it into the paste stack? Configurable behaviour?
AppState.shared.history.interruptPasteStack()
}

if Defaults[.ignoreEvents] {
if Defaults[.ignoreOnlyNextEvent] {
Defaults[.ignoreEvents] = false
Expand Down
69 changes: 58 additions & 11 deletions Maccy/Extensions/Collection+Surrounding.swift
Original file line number Diff line number Diff line change
@@ -1,28 +1,75 @@
extension Collection where Element: Equatable {
func item(after: Element) -> Element? {
func item(after: Element, where predicate: (Element) -> Bool) -> Element? {
guard let currentIndex = firstIndex(of: after) else {
return nil
}

let nextIndex = index(currentIndex, offsetBy: 1)
if nextIndex < endIndex {
return self[nextIndex]
} else {
return nil
var nextIndex = index(currentIndex, offsetBy: 1)
while nextIndex < endIndex {
let item = self[nextIndex]
if predicate(item) {
return item
}
nextIndex = index(nextIndex, offsetBy: 1)
}
return nil
}

func item(before: Element) -> Element? {
func item(before: Element, where predicate: (Element) -> Bool) -> Element? {
guard let currentIndex = firstIndex(of: before) else {
return nil
}

let prevIndex = index(currentIndex, offsetBy: -1)
if prevIndex >= startIndex {
return self[prevIndex]
} else {
var prevIndex = index(currentIndex, offsetBy: -1)
while prevIndex >= startIndex {
let item = self[prevIndex]
if predicate(item) {
return item
}
prevIndex = index(prevIndex, offsetBy: -1)
}
return nil
}

func between(from fromElement: Element, to toElement: Element, inOrder: Bool = false) -> [Element]? {
guard let fromIndex = firstIndex(of: fromElement) else {
return nil
}
guard let toIndex = firstIndex(of: toElement) else {
return nil
}
let startIndex = Swift.min(fromIndex, toIndex)
let endIndex = Swift.max(fromIndex, toIndex)
let items = self[startIndex...endIndex]
if !inOrder && fromIndex > toIndex {
return items.reversed()
} else {
return Array(items)
}
}
}

extension Array where Element: Equatable {
func nearest(to element: Element, where condition: (Element) -> Bool) -> Element? {
guard let currentIndex = firstIndex(of: element) else {
return nil
}
let nextNearest = self[currentIndex...].firstIndex(where: { condition($0) })
let previousNearest = self[...currentIndex].lastIndex(where: { condition($0) })
switch (nextNearest, previousNearest) {
case (nil, nil):
return nil
case (.some(let index), .none):
return self[currentIndex + index]
case (.none, .some(let index)):
return self[index]
case (.some(let index1), .some(let index2)):
let pos1 = currentIndex + index1
let pos2 = index2
return abs(pos1 - currentIndex) < abs(pos2 - currentIndex)
? self[pos1]
: self[pos2]
}

}
}
1 change: 1 addition & 0 deletions Maccy/Extensions/Defaults.Keys+Names.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@ extension Defaults.Keys {
static let windowSize = Key<NSSize>("windowSize", default: NSSize(width: 450, height: 800))
static let windowPosition = Key<NSPoint>("windowPosition", default: NSPoint(x: 0.5, y: 0.8))
static let showApplicationIcons = Key<Bool>("showApplicationIcons", default: false)
static let previewWidth = Key<CGFloat>("previewWidth", default: 400)
}
1 change: 1 addition & 0 deletions Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ extension KeyboardShortcuts.Name {
static let popup = Self("popup", default: Shortcut(.c, modifiers: [.command, .shift]))
static let pin = Self("pin", default: Shortcut(.p, modifiers: [.option]))
static let delete = Self("delete", default: Shortcut(.delete, modifiers: [.option]))
static let togglePreview = Self("togglePreview", default: Shortcut(.space, modifiers: [.control]))
}
5 changes: 3 additions & 2 deletions Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ extension NSPasteboard.PasteboardType: Defaults.Serializable {

// Safari preview and extra metadata that changes frequently.
static let linkPresentationMetadata = NSPasteboard.PasteboardType(rawValue: "com.apple.linkpresentation.metadata")
// swiftlint:disable:next line_length
static let customWebKitPasteboardData = NSPasteboard.PasteboardType(rawValue: "com.apple.WebKit.custom-pasteboard-data")

// Chromium (VSCode)
static let customChromiumWebData = NSPasteboard.PasteboardType(rawValue: "org.chromium.web-custom-data")
static let chromiumSourceUrl = NSPasteboard.PasteboardType(rawValue: "org.chromium.source-url")
static let chromiumSourceToken = NSPasteboard.PasteboardType(rawValue: "org.chromium.internal.source-rfh-token")

// Apple Notes
static let notesRichText = NSPasteboard.PasteboardType(rawValue: "com.apple.notes.richtext")
}
87 changes: 80 additions & 7 deletions Maccy/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
}

func open(height: CGFloat, at popupPosition: PopupPosition = Defaults[.popupPosition]) {
setContentSize(NSSize(width: frame.width, height: min(height, Defaults[.windowSize].height)))
let size = Defaults[.windowSize]
setContentSize(NSSize(width: min(frame.width, size.width), height: min(height, size.height)))
setFrameOrigin(popupPosition.origin(size: frame.size, statusBarButton: statusBarButton))
orderFrontRegardless()
makeKey()
Expand All @@ -86,9 +87,8 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
}

func verticallyResize(to newHeight: CGFloat) {
var newSize = Defaults[.windowSize]
newSize.height = min(newHeight, newSize.height)

var newSize = frame.size
newSize.height = newHeight
var newOrigin = frame.origin
newOrigin.y += (frame.height - newSize.height)

Expand All @@ -98,9 +98,19 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
}
}

func determinePreviewPlacement() {
let preview = AppState.shared.preview
guard !preview.state.isOpen else { return }
let newSize = preview.computeSizeWithPreview(frame.size, state: .open)
preview.placement = preview.computePlacement(window: self, for: newSize)
}

func saveWindowPosition() {
if let screenFrame = screen?.visibleFrame {
let anchorX = frame.minX + frame.width / 2 - screenFrame.minX
// Only store the size of the window without the preview
let width = AppState.shared.preview.contentWidth

let anchorX = frame.minX + width / 2 - screenFrame.minX
let anchorY = frame.maxY - screenFrame.minY
Defaults[.windowPosition] = NSPoint(x: anchorX / screenFrame.width, y: anchorY / screenFrame.height)
}
Expand All @@ -112,9 +122,71 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
}

func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
saveWindowFrame(frame: NSRect(origin: frame.origin, size: frameSize))
let preview = AppState.shared.preview

if inLiveResize && preview.resizingMode == .none {
let screenPoint = NSEvent.mouseLocation
let windowPoint = convertPoint(fromScreen: screenPoint)
let location: SlideoutPlacement = windowPoint.x <= frame.width / 2 ? .left : .right
if (location == preview.placement) && preview.state == .open {
preview.startResize(mode: .slideout)
} else {
preview.startResize(mode: .content)
}
}

var finalFrameSize = frameSize
var minContent = preview.minimumContentWidth
var minPreview = 0.0

if inLiveResize && preview.resizingMode != .none {
if preview.resizingMode == .content && preview.state == .open {
minPreview = preview.slideoutWidth
}
if preview.resizingMode == .slideout {
minPreview = preview.minimumSlideoutWidth
minContent = preview.contentWidth
}
}
finalFrameSize.width = max(finalFrameSize.width, minContent + minPreview)

if !AppState.shared.preview.state.isAnimating {
var size = frame.size
// Only store the size of the window without the preview
size.width = AppState.shared.preview.contentWidth
saveWindowFrame(frame: NSRect(origin: frame.origin, size: size))
}

return finalFrameSize
}

func windowWillMove(_ notification: Notification) {
determinePreviewPlacement()
}

func windowDidMove(_ notification: Notification) {
determinePreviewPlacement()
}

func windowWillStartLiveResize(_ notification: Notification) {
AppState.shared.preview.cancelAutoOpen()
}

func windowDidEndLiveResize(_ notification: Notification) {
AppState.shared.preview.startAutoOpen()
AppState.shared.preview.endResize()
}

func windowDidBecomeKey(_ notification: Notification) {
AppState.shared.preview.enableAutoOpen()

if AppState.shared.navigator.leadHistoryItem != nil {
AppState.shared.preview.startAutoOpen()
}
}

return frameSize
func windowDidResignKey(_ notification: Notification) {
AppState.shared.preview.disableAutoOpen()
}

// Close automatically when out of focus, e.g. outside click.
Expand All @@ -128,6 +200,7 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {

override func close() {
super.close()
AppState.shared.preview.state = .closed
isPresented = false
statusBarButton?.isHighlighted = false
onClose()
Expand Down
2 changes: 1 addition & 1 deletion Maccy/Intents/Get.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct Get: AppIntent, CustomIntentMigratedAppIntent {
func perform() async throws -> some IntentResult & ReturnsValue<HistoryItemAppEntity> {
var item: HistoryItem?
if selected {
item = AppState.shared.history.selectedItem?.item
item = AppState.shared.navigator.selection.first?.item
} else {
let index = number - positionOffset
if AppState.shared.history.items.count >= index {
Expand Down
49 changes: 49 additions & 0 deletions Maccy/ItemsProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
protocol HasVisibility {
var isVisible: Bool { get }
}

protocol ItemsContainer {
associatedtype Item
var containerVisible: Bool { get }
var items: [Item] { get set }
}

extension ItemsContainer {
var containerVisible: Bool { true }
}

private extension ItemsContainer where Item: HasVisibility {}

extension ItemsContainer where Item: HasVisibility {

var visibleItems: [Item] {
guard containerVisible else { return [] }
return self.items.lazy.filter(\.isVisible)
}

var firstVisibleItem: Item? {
guard containerVisible else { return nil }
return self.items.first(where: \.isVisible)
}
func firstVisibleItem(where predicate: (Item) -> Bool) -> Item? {
guard containerVisible else { return nil }
return self.items.first { $0.isVisible && predicate($0) }
}
var lastVisibleItem: Item? {
guard containerVisible else { return nil }
return self.items.last(where: \.isVisible)
}
func lastVisibleItem(where predicate: (Item) -> Bool) -> Item? {
guard containerVisible else { return nil }
return self.items.last { $0.isVisible && predicate($0) }
}
}

extension ItemsContainer where Item: HasVisibility, Item: Equatable {
func visibleItem(before: Item) -> Item? {
return self.items.item(before: before, where: \.isVisible)
}
func visibleItem(after: Item) -> Item? {
return self.items.item(after: after, where: \.isVisible)
}
}
Loading