Skip to content

Commit 68b24e1

Browse files
authored
Merge pull request farouqaldori#4 from MarcinusX/feature/screen-selection
Add screen selection setting for multi-monitor support
2 parents b653603 + 77818c6 commit 68b24e1

9 files changed

Lines changed: 558 additions & 169 deletions

File tree

ClaudeIsland.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@
297297
"$(inherited)",
298298
"@executable_path/../Frameworks",
299299
);
300-
MARKETING_VERSION = 1.1;
300+
MARKETING_VERSION = 1.2;
301301
PRODUCT_BUNDLE_IDENTIFIER = com.celestial.ClaudeIsland;
302302
PRODUCT_NAME = "Claude Island";
303303
REGISTER_APP_GROUPS = YES;
@@ -332,7 +332,7 @@
332332
"$(inherited)",
333333
"@executable_path/../Frameworks",
334334
);
335-
MARKETING_VERSION = 1.1;
335+
MARKETING_VERSION = 1.2;
336336
PRODUCT_BUNDLE_IDENTIFIER = com.celestial.ClaudeIsland;
337337
PRODUCT_NAME = "Claude Island";
338338
REGISTER_APP_GROUPS = YES;

ClaudeIsland/App/WindowManager.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ class WindowManager {
1616

1717
/// Set up or recreate the notch window
1818
func setupNotchWindow() -> NotchWindowController? {
19-
// Find the screen with the notch (built-in display), or fallback to main
20-
let screen = NSScreen.builtin ?? NSScreen.main
19+
// Use ScreenSelector for screen selection
20+
let screenSelector = ScreenSelector.shared
21+
screenSelector.refreshScreens()
2122

22-
guard let screen = screen else {
23+
guard let screen = screenSelector.selectedScreen else {
2324
logger.warning("No screen found")
2425
return nil
2526
}

ClaudeIsland/Core/NotchViewModel.swift

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,12 @@ enum NotchOpenReason {
2626
enum NotchContentType: Equatable {
2727
case instances
2828
case menu
29-
case soundSelection
3029
case chat(SessionState)
3130

3231
var id: String {
3332
switch self {
3433
case .instances: return "instances"
3534
case .menu: return "menu"
36-
case .soundSelection: return "soundSelection"
3735
case .chat(let session): return "chat-\(session.sessionId)"
3836
}
3937
}
@@ -48,6 +46,11 @@ class NotchViewModel: ObservableObject {
4846
@Published var contentType: NotchContentType = .instances
4947
@Published var isHovering: Bool = false
5048

49+
// MARK: - Dependencies
50+
51+
private let screenSelector = ScreenSelector.shared
52+
private let soundSelector = SoundSelector.shared
53+
5154
// MARK: - Geometry
5255

5356
let geometry: NotchGeometry
@@ -71,13 +74,7 @@ class NotchViewModel: ObservableObject {
7174
// Compact size for settings menu
7275
return CGSize(
7376
width: min(screenRect.width * 0.4, 480),
74-
height: 420
75-
)
76-
case .soundSelection:
77-
// Taller size for sound selection list
78-
return CGSize(
79-
width: min(screenRect.width * 0.4, 480),
80-
height: 500
77+
height: 420 + screenSelector.expandedPickerHeight + soundSelector.expandedPickerHeight
8178
)
8279
case .instances:
8380
return CGSize(
@@ -109,6 +106,17 @@ class NotchViewModel: ObservableObject {
109106
)
110107
self.hasPhysicalNotch = hasPhysicalNotch
111108
setupEventHandlers()
109+
observeSelectors()
110+
}
111+
112+
private func observeSelectors() {
113+
screenSelector.$isPickerExpanded
114+
.sink { [weak self] _ in self?.objectWillChange.send() }
115+
.store(in: &cancellables)
116+
117+
soundSelector.$isPickerExpanded
118+
.sink { [weak self] _ in self?.objectWillChange.send() }
119+
.store(in: &cancellables)
112120
}
113121

114122
// MARK: - Event Handling
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//
2+
// ScreenSelector.swift
3+
// ClaudeIsland
4+
//
5+
// Manages screen selection state and persistence
6+
//
7+
8+
import AppKit
9+
import Combine
10+
import Foundation
11+
12+
/// Strategy for selecting which screen to use
13+
enum ScreenSelectionMode: String, Codable {
14+
case automatic // Prefer built-in display, fall back to main
15+
case specificScreen // User selected a specific screen
16+
}
17+
18+
/// Persistent identifier for a screen
19+
struct ScreenIdentifier: Codable, Equatable, Hashable {
20+
let displayID: CGDirectDisplayID?
21+
let localizedName: String
22+
23+
/// Create identifier from NSScreen
24+
init(screen: NSScreen) {
25+
if let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID {
26+
self.displayID = screenNumber
27+
} else {
28+
self.displayID = nil
29+
}
30+
self.localizedName = screen.localizedName
31+
}
32+
33+
/// Check if this identifier matches a given screen
34+
func matches(_ screen: NSScreen) -> Bool {
35+
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
36+
return localizedName == screen.localizedName
37+
}
38+
// Primary match: displayID (most reliable when connected)
39+
if let savedID = displayID, savedID == screenNumber {
40+
return true
41+
}
42+
// Fallback: name match (for reconnected displays with new IDs)
43+
return localizedName == screen.localizedName
44+
}
45+
}
46+
47+
@MainActor
48+
class ScreenSelector: ObservableObject {
49+
static let shared = ScreenSelector()
50+
51+
// MARK: - Published State
52+
@Published private(set) var availableScreens: [NSScreen] = []
53+
@Published private(set) var selectedScreen: NSScreen?
54+
@Published var selectionMode: ScreenSelectionMode = .automatic
55+
@Published var isPickerExpanded: Bool = false
56+
57+
// MARK: - UserDefaults Keys
58+
private let modeKey = "screenSelectionMode"
59+
private let screenIdentifierKey = "selectedScreenIdentifier"
60+
61+
// MARK: - Private State
62+
private var savedIdentifier: ScreenIdentifier?
63+
64+
private init() {
65+
loadPreferences()
66+
refreshScreens()
67+
}
68+
69+
// MARK: - Public API
70+
71+
/// Refresh the available screens list
72+
func refreshScreens() {
73+
availableScreens = NSScreen.screens
74+
selectedScreen = resolveSelectedScreen()
75+
}
76+
77+
/// Select a specific screen
78+
func selectScreen(_ screen: NSScreen) {
79+
selectionMode = .specificScreen
80+
savedIdentifier = ScreenIdentifier(screen: screen)
81+
selectedScreen = screen
82+
savePreferences()
83+
}
84+
85+
/// Reset to automatic selection
86+
func selectAutomatic() {
87+
selectionMode = .automatic
88+
savedIdentifier = nil
89+
selectedScreen = resolveSelectedScreen()
90+
savePreferences()
91+
}
92+
93+
/// Check if a screen is currently selected
94+
func isSelected(_ screen: NSScreen) -> Bool {
95+
guard let selected = selectedScreen else { return false }
96+
return screenID(of: screen) == screenID(of: selected)
97+
}
98+
99+
/// Extra height needed when picker is expanded
100+
var expandedPickerHeight: CGFloat {
101+
guard isPickerExpanded else { return 0 }
102+
// +1 for "Automatic" option
103+
return CGFloat(availableScreens.count + 1) * 40
104+
}
105+
106+
// MARK: - Private Methods
107+
108+
private func screenID(of screen: NSScreen) -> CGDirectDisplayID? {
109+
screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
110+
}
111+
112+
private func resolveSelectedScreen() -> NSScreen? {
113+
switch selectionMode {
114+
case .automatic:
115+
return NSScreen.builtin ?? NSScreen.main
116+
117+
case .specificScreen:
118+
// Try to find the saved screen
119+
if let identifier = savedIdentifier,
120+
let match = availableScreens.first(where: { identifier.matches($0) }) {
121+
return match
122+
}
123+
// Saved screen not found - fall back to automatic
124+
return NSScreen.builtin ?? NSScreen.main
125+
}
126+
}
127+
128+
private func loadPreferences() {
129+
if let modeString = UserDefaults.standard.string(forKey: modeKey),
130+
let mode = ScreenSelectionMode(rawValue: modeString) {
131+
selectionMode = mode
132+
}
133+
134+
if let data = UserDefaults.standard.data(forKey: screenIdentifierKey),
135+
let identifier = try? JSONDecoder().decode(ScreenIdentifier.self, from: data) {
136+
savedIdentifier = identifier
137+
}
138+
}
139+
140+
private func savePreferences() {
141+
UserDefaults.standard.set(selectionMode.rawValue, forKey: modeKey)
142+
143+
if let identifier = savedIdentifier,
144+
let data = try? JSONEncoder().encode(identifier) {
145+
UserDefaults.standard.set(data, forKey: screenIdentifierKey)
146+
} else {
147+
UserDefaults.standard.removeObject(forKey: screenIdentifierKey)
148+
}
149+
}
150+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// SoundSelector.swift
3+
// ClaudeIsland
4+
//
5+
// Manages sound selection state for the settings menu
6+
//
7+
8+
import Combine
9+
import Foundation
10+
11+
@MainActor
12+
class SoundSelector: ObservableObject {
13+
static let shared = SoundSelector()
14+
15+
// MARK: - Published State
16+
17+
@Published var isPickerExpanded: Bool = false
18+
19+
// MARK: - Constants
20+
21+
/// Maximum number of sound options to show before scrolling
22+
private let maxVisibleOptions = 6
23+
24+
/// Height per sound option row
25+
private let rowHeight: CGFloat = 32
26+
27+
private init() {}
28+
29+
// MARK: - Public API
30+
31+
/// Extra height needed when picker is expanded (capped for scrolling)
32+
var expandedPickerHeight: CGFloat {
33+
guard isPickerExpanded else { return 0 }
34+
let totalOptions = NotificationSound.allCases.count
35+
let visibleOptions = min(totalOptions, maxVisibleOptions)
36+
return CGFloat(visibleOptions) * rowHeight + 8 // +8 for padding
37+
}
38+
}

0 commit comments

Comments
 (0)