CutBox.app

Coverage Report

Created: 2024-03-12 03:40

.../Source/App/SearchAndPreview/SearchViewController.swift
Line
Count
Source (jump to first uncovered line)
1
//
2
//  SearchViewController.swift
3
//  CutBox
4
//
5
//  Created by Jason Milkins on 3/4/18.
6
//  Copyright © 2018-2023 ocodo. All rights reserved.
7
//
8
9
import Cocoa
10
import RxSwift
11
12
/// Controller for `SearchAndPreviewView`
13
///
14
/// Subscribes any `SearchViewEvents` (from `SearchAndPreviewView`),
15
/// performing the required actions,  using init injected: `searchView:SearchAndPreviewView`,
16
/// `historyService:HistoryService` and `prefs:CutBoxPreferencesService`.
17
class SearchViewController: NSObject {
18
    var searchView: SearchAndPreviewView
19
    var historyService: HistoryService
20
    var prefs: CutBoxPreferencesService
21
    var fakeKey: FakeKey
22
23
75
    var orderedSelection: OrderedSet<Int> = OrderedSet<Int>()
24
25
    var searchPopup: PopupController
26
27
    /// Event stream from SearchAndPreviewView
28
82
    var events: PublishSubject<SearchViewEvents> {
29
82
        return self.searchView.events
30
82
    }
31
32
    /// Row indexes of selected items
33
2
    var selectedItems: IndexSet? {
34
2
        return self.searchView.selectedItems
35
2
    }
36
37
    /// Strings of selected items
38
6
    var selectedClips: [String] {
39
6
        if self.historyService.items.isEmpty {
40
2
            return []
41
4
        }
42
4
43
4
        return self.orderedSelection.all()
44
4
            .map { self.historyService.items[$0] }
45
6
    }
46
47
75
    let disposeBag = DisposeBag()
48
49
    /// Setup controller, initialize the search view and popup.
50
    /// Connect the history service and tell it to start polling the pasteboard.
51
    /// Connect the preferences service to self and members.
52
    init(historyService: HistoryService = HistoryService.shared,
53
         cutBoxPreferences: CutBoxPreferencesService = CutBoxPreferencesService.shared,
54
         fakeKey: FakeKey = FakeKey(),
55
         searchView: SearchAndPreviewView = SearchAndPreviewView.fromNib()!
56
75
    ) {
57
75
        self.historyService = historyService
58
75
        self.prefs = cutBoxPreferences
59
75
        self.fakeKey = fakeKey
60
75
61
75
        self.searchView = searchView
62
75
        self.searchPopup = PopupController(content: self.searchView)
63
75
64
75
        self.historyService.beginPolling()
65
75
66
75
        super.init()
67
75
68
75
        configureSearchPopupAndView()
69
75
    }
70
71
    /// Setup the context menu for items shown in the search view
72
75
    func setupClipItemsContextMenu() {
73
75
        self.searchView.setupClipItemsContextMenu()
74
75
        self.searchView.menuDelegate = self
75
75
    }
76
77
    /// Toggle display of the search popup
78
0
    func togglePopup() {
79
0
        self.historyService.favoritesOnly = false
80
0
        self.searchView.applyTheme()
81
0
        self.searchPopup.togglePopup()
82
0
    }
83
84
    /// Close the search popup
85
2
    func closeSearchPopup() {
86
2
        self.historyService.favoritesOnly = false
87
2
        self.searchPopup.closePopup()
88
2
    }
89
90
    /// Open the search popup
91
0
    func openSearchPopup() {
92
0
        self.historyService.favoritesOnly = false
93
0
        self.searchPopup.openPopup()
94
0
    }
95
96
    /// Hide the app (objc selector)
97
2
    @objc func hideApp() {
98
2
        NSApp.hide(self)
99
2
    }
100
101
    /// send a fake paste (Cmd V) to macOS
102
1
    @objc func fakePaste() {
103
1
        fakeKey.send(fakeKey: "V", useCommandFlag: true)
104
1
    }
105
106
1
    private func justClose() {
107
1
        self.closeSearchPopup()
108
1
        perform(#selector(hideApp), with: self, afterDelay: 0.1)
109
1
    }
110
111
1
    private func closeAndPaste(useJS: Bool = false) {
112
1
        self.pasteSelectedClipToPasteboard(useJS)
113
1
        self.closeSearchPopup()
114
1
        perform(#selector(hideApp), with: self, afterDelay: 0.1)
115
1
        perform(#selector(fakePaste), with: self, afterDelay: 0.25)
116
1
    }
117
118
    /// Remove selected items and refresh the search view
119
1
    func removeSelectedItems() {
120
1
        if let selection = self.selectedItems {
121
1
            self.historyService.remove(selected: selection)
122
1
            self.searchView.reloadData()
123
1
        }
124
1
    }
125
126
    /// Toggle favorite status on selected items
127
1
    func toggleFavoriteItems() {
128
1
        if  let selection = self.selectedItems {
129
1
            self.historyService.toggleFavorite(items: selection)
130
1
            self.searchView.reloadData()
131
1
            self.searchView.selectRowIndexes(selection, byExtendingSelection: false)
132
1
        }
133
1
    }
134
135
    /// Send the selected clip text to the pasteboard
136
1
    func pasteSelectedClipToPasteboard(_ useJS: Bool) {
137
1
        guard !self.selectedClips.isEmpty else {
138
1
            return
139
1
        }
140
0
141
0
        pasteToPasteboard(self.selectedClips)
142
0
    }
143
144
0
    private func pasteToPasteboard(_ clips: [String]) {
145
0
        let clip = prefs.prepareClips(clips)
146
0
147
0
        NSPasteboard.general.clearContents()
148
0
        NSPasteboard.general.setString(clip, forType: .string)
149
0
    }
150
151
0
    private func pasteToPasteboard(_ clip: String) {
152
0
        NSPasteboard.general.clearContents()
153
0
        NSPasteboard.general.setString(clip, forType: .string)
154
0
    }
155
156
2
    private func resetSearchText() {
157
2
        self.searchView.searchText?.string = ""
158
2
        self.searchView.filterTextPublisher.onNext("")
159
2
        self.searchView.reloadData()
160
2
    }
161
162
75
    private func setupSearchViewAndFilterBinding() {
163
75
        self.searchView.itemsDataSource = self
164
75
        self.searchView.itemsDelegate = self
165
75
166
75
        self.searchView.filterTextPublisher
167
75
            .map { $0.isEmpty }
168
75
            .subscribe(onNext: {
169
2
                if self.prefs.useCompactUI {
170
0
                    self.searchView.hideSearchResults($0)
171
2
                } else {
172
2
                    self.searchView.hideSearchResults(false)
173
2
                }
174
2
            })
175
75
            .disposed(by: disposeBag)
176
75
177
75
        self.searchView.filterTextPublisher
178
75
            .subscribe(onNext: {
179
2
                self.historyService.filterText = $0
180
2
                self.searchView.reloadData()
181
2
                self.searchView.scrollRowToVisible(0)
182
2
            })
183
75
            .disposed(by: self.disposeBag)
184
75
185
75
        self.prefs.events
186
5.76k
            .compactMap {
187
5.76k
                if case .hidePreviewSettingChanged(let isOn) = $0 {
188
1.15k
                    return isOn
189
4.60k
                }
190
4.60k
                return nil
191
5.76k
            }
192
1.15k
            .subscribe(onNext: {
193
1.15k
                self.searchView.hidePreview($0)
194
1.15k
            })
195
75
            .disposed(by: self.disposeBag)
196
75
    }
197
198
    /// Update the preview with selected clip(s)
199
4
    func updateSearchItemPreview() {
200
4
        let preview = prefs.prepareClips(selectedClips)
201
4
        self.searchView.previewString = preview.truncate(limit: 50_000)
202
4
    }
203
204
    // swiftlint:disable cyclomatic_complexity
205
    // swiftlint:disable function_body_length
206
75
    private func setupSearchTextEventBindings() {
207
75
        self.events
208
76
            .subscribe(onNext: onNext)
$s15CutBoxUnitTests20SearchViewControllerC05setupE17TextEventBindings33_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyAA0eF6EventsOcACcfu_
Line
Count
Source
208
75
            .subscribe(onNext: onNext)
$s15CutBoxUnitTests20SearchViewControllerC05setupE17TextEventBindings33_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyAA0eF6EventsOcACcfu_yAGcfu0_
Line
Count
Source
208
1
            .subscribe(onNext: onNext)
209
75
            .disposed(by: self.disposeBag)
210
75
    }
211
212
18
    func onNext(event: SearchViewEvents) {
213
18
        switch event {
214
18
        case .setSearchMode(let mode):
215
2
            self.historyService.searchMode = mode
216
2
            self.searchView.reloadData()
217
2
            self.searchView.setSearchModeButton(mode: mode)
218
18
219
18
        case .toggleSearchMode:
220
2
            let mode = self.historyService.toggleSearchMode()
221
2
            self.searchView.reloadData()
222
2
            self.searchView.setSearchModeButton(mode: mode)
223
18
224
18
        case .setTimeFilter(let seconds):
225
1
            self.historyService.setTimeFilter(seconds: seconds)
226
1
            self.searchView.reloadData()
227
18
228
18
        case .toggleTimeFilter:
229
1
            self.searchView.toggleTimeFilter()
230
1
            self.historyService.setTimeFilter(seconds: nil)
231
1
            self.searchView.reloadData()
232
18
233
18
        case .cycleTheme:
234
1
            self.prefs.cycleTheme()
235
1
            self.searchView.applyTheme()
236
1
            self.reloadDataWithExistingSelection()
237
18
238
18
        case .toggleWrappingStrings:
239
1
            self.prefs.useWrappingStrings.toggle()
240
1
            self.updateSearchItemPreview()
241
18
242
18
        case .toggleJoinStrings:
243
1
            self.prefs.useJoinString.toggle()
244
1
            self.updateSearchItemPreview()
245
18
246
18
        case .toggleSearchScope:
247
1
            self.historyService.favoritesOnly.toggle()
248
1
            self.searchView.reloadData()
249
1
            self.searchView.setSearchScopeButton(favoritesOnly: self.historyService.favoritesOnly)
250
18
251
18
        case .togglePreview:
252
1
            self.prefs.hidePreview.toggle()
253
18
254
18
        case .scaleTextDown:
255
1
            self.prefs.scaleTextDown()
256
1
            self.reloadDataWithExistingSelection()
257
18
258
18
        case .scaleTextUp:
259
1
            self.prefs.scaleTextUp()
260
1
            self.reloadDataWithExistingSelection()
261
18
262
18
        case .scaleTextNormalize:
263
1
            self.prefs.scaleTextNormalize()
264
1
            self.reloadDataWithExistingSelection()
265
18
266
18
        case .toggleFavorite:
267
1
            self.toggleFavoriteItems()
268
18
269
18
        case .justClose:
270
1
            self.justClose()
271
18
272
18
        case .closeAndPasteSelected:
273
1
            self.closeAndPaste()
274
18
275
18
        case .removeSelected:
276
1
            self.removeSelectedItems()
277
18
278
18
        default:
279
0
            break
280
18
        }
281
18
    }
282
    // swiftlint:enable cyclomatic_complexity
283
    // swiftlint:enable function_body_length
284
285
4
    private func reloadDataWithExistingSelection() {
286
4
        if let selected = self.searchView.selectedRowIndexes {
287
4
            self.searchView.updateLayer()
288
4
            self.searchView.reloadData()
289
4
            self.searchView.setTextScale()
290
4
            self.searchView.selectRowIndexes(selected, byExtendingSelection: false)
291
4
        }
292
4
    }
293
294
75
    private func configureSearchPopupAndView() {
295
75
        setupSearchTextEventBindings()
296
75
        setupSearchViewAndFilterBinding()
297
75
        setupClipItemsContextMenu()
298
75
299
75
        self.searchView.placeHolderTextString = "search_placeholder".l7n
300
75
301
75
        self.searchPopup.proportionalTopPadding = 0.15
302
75
        self.searchPopup.proportionalWidth = 0.6
303
75
        self.searchPopup.proportionalHeight = 0.6
304
75
305
75
        self.searchPopup.willOpenPopup = self.searchPopup.proportionalResizePopup
$s15CutBoxUnitTests20SearchViewControllerC09configuree8PopupAndF033_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyycAA0iG0Ccfu_
Line
Count
Source
305
75
        self.searchPopup.willOpenPopup = self.searchPopup.proportionalResizePopup
Unexecuted instantiation: $s15CutBoxUnitTests20SearchViewControllerC09configuree8PopupAndF033_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyycAA0iG0Ccfu_yycfu0_
306
75
307
75
        self.searchPopup.didOpenPopup = {
308
0
            guard let window = self.searchView.window
309
0
                else { fatalError("No window found for popup") }
310
0
311
0
            self.resetSearchText()
312
0
313
0
            // Focus search text
314
0
            window.makeFirstResponder(self.searchView.searchText)
315
0
        }
316
75
317
77
        self.searchPopup.willClosePopup = self.resetSearchText
$s15CutBoxUnitTests20SearchViewControllerC09configuree8PopupAndF033_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyycACcfu1_
Line
Count
Source
317
75
        self.searchPopup.willClosePopup = self.resetSearchText
$s15CutBoxUnitTests20SearchViewControllerC09configuree8PopupAndF033_EB8AC02C75C5D49A81C9F81D503666B0LLyyFyycACcfu1_yycfu2_
Line
Count
Source
317
2
        self.searchPopup.willClosePopup = self.resetSearchText
318
75
    }
319
}