Skip to content

Commit 2235156

Browse files
committed
feat: Add SearchSuggestionsFeature
This reverts commit 70ee5fd. - feat: WIP Add `TCATextView` - chore: Add `autoSuggest` higher-order `Reducer` - feat: Introduce `AutoSuggestList` - feat: Introduce `.attachedWindow` - chore: Allow using the attached window corner radius from everywhere - feat: Use `CustomList` to avoid height computation
1 parent dc70ee3 commit 2235156

24 files changed

+2549
-12
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ XCBEAUTIFY = ./BuildTools/.build/release/xcbeautify
3333
XCODEBUILD = set -o pipefail && xcodebuild
3434
XCPROJ = Prose/Prose.xcodeproj
3535
XCSCHEME = Prose
36-
PREVIEW_SCHEMES = ConversationFeaturePreview EditProfileFeaturePreview
36+
PREVIEW_SCHEMES = ConversationFeaturePreview EditProfileFeaturePreview ProseUIPreview SearchSuggestionsFeaturePreview
3737

3838
preflight: lint test release_build build_preview_apps
3939

Prose/Prose.xcodeproj/project.pbxproj

Lines changed: 273 additions & 11 deletions
Large diffs are not rendered by default.

Prose/ProseLib/Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let package = Package(
2020
"JoinChatFeature",
2121
"MainWindowFeature",
2222
"ProseUI",
23+
"SearchSuggestionsFeature",
2324
"SettingsFeature",
2425
"SidebarFeature",
2526
"UnreadFeature",
@@ -102,6 +103,10 @@ let package = Package(
102103
.product(name: "SwiftUINavigation", package: "swiftui-navigation"),
103104
]
104105
),
106+
.target(name: "SearchSuggestionsFeature", dependencies: [
107+
"AutoSuggestClient",
108+
.featureBase,
109+
]),
105110
.target(
106111
name: "UnreadFeature",
107112
dependencies: [
@@ -112,6 +117,8 @@ let package = Package(
112117

113118
// MARK: Dependencies
114119

120+
.target(name: "AutoSuggestClient", dependencies: [.base]),
121+
115122
.target(name: "CredentialsClient", dependencies: [.base]),
116123
.testTarget(name: "CredentialsClientTests", dependencies: ["CredentialsClient"]),
117124

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// This file is part of prose-app-macos.
3+
// Copyright (c) 2022 Prose Foundation
4+
//
5+
6+
import ComposableArchitecture
7+
import Foundation
8+
9+
public extension AutoSuggestClient {
10+
static func chooseFrom(
11+
_ suggestions: [AutoSuggestSection<T>],
12+
on searchFields: @escaping (T) -> Set<String>,
13+
delay: DispatchQueue.SchedulerTimeType.Stride = 0.125,
14+
mainQueue: AnySchedulerOf<DispatchQueue> = .main
15+
) -> AutoSuggestClient<T> {
16+
AutoSuggestClient(
17+
loadSuggestions: { searchQuery, excluded in
18+
if searchQuery.isEmpty { return Effect(value: []) }
19+
20+
let filteredSuggestions: [AutoSuggestSection<T>] = suggestions.compactMap { section in
21+
let filteredItems: [T] = section.items.filter { suggestion -> Bool in
22+
let fields: Set<String> = searchFields(suggestion)
23+
24+
// Make sure no term is excluded (e.g. already in search field token)
25+
guard fields.intersection(excluded).isEmpty else { return false }
26+
27+
// Search for a match in the fields (e.g. search in the JID and the full name)
28+
return fields.contains { string -> Bool in
29+
// NOTE: "The search is locale-aware, case and diacritic insensitive."
30+
string.localizedStandardContains(searchQuery)
31+
}
32+
}
33+
34+
return filteredItems.isEmpty ? nil : AutoSuggestSection(
35+
title: section.title,
36+
items: filteredItems
37+
)
38+
}
39+
40+
return Effect(value: filteredSuggestions)
41+
// A small delay pretend there was a network call
42+
.delay(for: delay, scheduler: mainQueue)
43+
.eraseToEffect()
44+
}
45+
)
46+
}
47+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// This file is part of prose-app-macos.
3+
// Copyright (c) 2022 Prose Foundation
4+
//
5+
6+
import Combine
7+
import ComposableArchitecture
8+
import Foundation
9+
import IdentifiedCollections
10+
11+
public typealias SuggestionLoader<T: Hashable> = (
12+
_ searchQuery: String,
13+
_ excludedTerms: Set<String>
14+
) -> Effect<[AutoSuggestSection<T>], Error>
15+
16+
public struct AutoSuggestClient<T: Hashable> {
17+
public var loadSuggestions: SuggestionLoader<T>
18+
19+
public init(loadSuggestions: @escaping SuggestionLoader<T>) {
20+
self.loadSuggestions = loadSuggestions
21+
}
22+
}
23+
24+
public struct AutoSuggestSection<T: Hashable> {
25+
public var title: String
26+
public var items: [T]
27+
28+
public var isEmpty: Bool { self.items.isEmpty }
29+
30+
public init(title: String, items: [T]) {
31+
self.title = title
32+
self.items = items
33+
}
34+
}
35+
36+
public extension AutoSuggestSection where T: Identifiable {
37+
var identifiedItems: IdentifiedArrayOf<T> {
38+
IdentifiedArray(uniqueElements: self.items)
39+
}
40+
}
41+
42+
extension AutoSuggestSection: Identifiable {
43+
public var id: String { self.title }
44+
}
45+
46+
extension AutoSuggestSection: Comparable {
47+
public static func < (lhs: Self, rhs: Self) -> Bool {
48+
lhs.title < rhs.title
49+
}
50+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// This file is part of prose-app-macos.
3+
// Copyright (c) 2022 Prose Foundation
4+
//
5+
6+
import AppKit
7+
import SwiftUI
8+
9+
final class AttachedViewController<Content: View>: NSViewController {
10+
var content: Content
11+
12+
lazy var hc = NSHostingController(rootView: self.content)
13+
14+
init(content: Content) {
15+
self.content = content
16+
super.init(nibName: nil, bundle: nil)
17+
}
18+
19+
@available(*, unavailable)
20+
required init?(coder _: NSCoder) {
21+
fatalError("init(coder:) has not been implemented")
22+
}
23+
24+
override func loadView() {
25+
// Set `self.view` so AppKit doesn't try to load a `nib` file
26+
self.view = NSView()
27+
}
28+
29+
override func viewDidLoad() {
30+
super.viewDidLoad()
31+
32+
// Add the hosted view
33+
self.addChild(self.hc)
34+
self.view.addSubview(self.hc.view)
35+
36+
// Make sure the hosted view always fills the full view
37+
self.hc.view.translatesAutoresizingMaskIntoConstraints = false
38+
NSLayoutConstraint.activate([
39+
self.hc.view.topAnchor.constraint(equalTo: self.view.topAnchor),
40+
self.hc.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
41+
self.hc.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
42+
self.hc.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
43+
])
44+
}
45+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// This file is part of prose-app-macos.
3+
// Copyright (c) 2022 Prose Foundation
4+
//
5+
6+
import Combine
7+
import ComposableArchitecture
8+
import SwiftUI
9+
10+
public extension View {
11+
func attachedWindow<Content: View>(
12+
isPresented: Bool,
13+
@ViewBuilder content: () -> Content
14+
) -> some View {
15+
AttachedWindow(root: self, content: content, showWindow: isPresented)
16+
}
17+
}
18+
19+
private struct AttachedWindow<Root: View, Content: View>: NSViewRepresentable {
20+
let root: Root
21+
let content: Content
22+
let showWindow: Bool
23+
24+
public init(
25+
root: Root,
26+
@ViewBuilder content: () -> Content,
27+
showWindow: Bool
28+
) {
29+
self.root = root
30+
self.content = content()
31+
self.showWindow = showWindow
32+
}
33+
34+
public func makeNSView(context _: Context) -> NSHostingView<Root> {
35+
let view = NSHostingView(rootView: self.root)
36+
37+
// Prevent the view from filling up the whole screen
38+
view.translatesAutoresizingMaskIntoConstraints = false
39+
40+
return view
41+
}
42+
43+
public func updateNSView(_ view: NSHostingView<Root>, context: Context) {
44+
view.rootView = self.root
45+
view.invalidateIntrinsicContentSize()
46+
47+
DispatchQueue.main.async {
48+
if self.showWindow {
49+
context.coordinator.wc.showWindow(self.content, on: view)
50+
} else {
51+
context.coordinator.wc.orderOut()
52+
}
53+
}
54+
}
55+
56+
public func makeCoordinator() -> Coordinator {
57+
Coordinator(
58+
wc: { AttachedWindowController(vc: AttachedViewController(content: self.content)) }
59+
)
60+
}
61+
62+
public final class Coordinator: NSObject {
63+
let _wc: () -> AttachedWindowController<Content>
64+
65+
lazy var wc: AttachedWindowController<Content> = self._wc()
66+
67+
init(wc: @escaping () -> AttachedWindowController<Content>) {
68+
self._wc = wc
69+
}
70+
}
71+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//
2+
// This file is part of prose-app-macos.
3+
// Copyright (c) 2022 Prose Foundation
4+
//
5+
6+
import Cocoa
7+
import SwiftUI
8+
9+
public let attachedWindowControllerCornerRadius: CGFloat = 8
10+
11+
class AttachedWindowController<Content: View>: NSWindowController {
12+
let vc: AttachedViewController<Content>
13+
14+
init(vc: AttachedViewController<Content>) {
15+
self.vc = vc
16+
17+
let window = NSWindow(contentViewController: vc)
18+
19+
// Remove the title bar
20+
window.styleMask.remove(.titled)
21+
// Disable window resizing
22+
window.styleMask.remove(.resizable)
23+
// Remove the default background
24+
window.backgroundColor = .clear
25+
26+
let background = NSVisualEffectView()
27+
// Set the background to the system material used for HUDs
28+
background.material = .hudWindow
29+
// Activate the effect
30+
background.state = .active
31+
// Allow corner clipping (otherwise the radius won't apply)
32+
background.wantsLayer = true
33+
// Set the corner radius
34+
background.layer?.cornerRadius = attachedWindowControllerCornerRadius
35+
36+
let contentView = window.contentView!
37+
// Add the background under the hosted view
38+
contentView.addSubview(background, positioned: .below, relativeTo: vc.hc.view)
39+
40+
// Make sure the background always fills the full view
41+
background.translatesAutoresizingMaskIntoConstraints = false
42+
NSLayoutConstraint.activate([
43+
background.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
44+
background.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
45+
background.topAnchor.constraint(equalTo: contentView.topAnchor),
46+
background.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
47+
])
48+
49+
super.init(window: window)
50+
}
51+
52+
@available(*, unavailable)
53+
required init?(coder _: NSCoder) {
54+
fatalError("init(coder:) has not been implemented")
55+
}
56+
57+
func orderOut() {
58+
if let window = self.window, window.isVisible {
59+
window.orderOut(self)
60+
logger.trace("Window removed from screen")
61+
}
62+
}
63+
64+
func showWindow(_ content: Content, on view: NSView) {
65+
// Fail gracefully if we cannot show the window
66+
guard let viewWindow = view.window else {
67+
return logger.error("`view` has no `window`")
68+
}
69+
guard let window = self.window else {
70+
return logger.error("`window` is `nil`")
71+
}
72+
73+
// Show the window if needed
74+
if !window.isVisible {
75+
// Move the window under the text field
76+
var viewRect = view.convert(view.bounds, to: nil)
77+
viewRect = viewWindow.convertToScreen(viewRect)
78+
window.setFrameTopLeftPoint(viewRect.origin)
79+
80+
// Make the window a little bigger than the text field
81+
var frame = window.frame
82+
frame.size.width = view.frame.width + 16
83+
frame.origin.x -= 8
84+
window.setFrame(frame, display: false)
85+
86+
// Add the window on screen
87+
viewWindow.addChildWindow(window, ordered: .above)
88+
89+
logger.trace("Window added on screen")
90+
}
91+
92+
// Update the view
93+
self.vc.hc.rootView = content
94+
}
95+
}

0 commit comments

Comments
 (0)