|
4 | 4 | // |
5 | 5 |
|
6 | 6 | import SwiftUI |
| 7 | +import AppKit |
7 | 8 |
|
8 | 9 | struct MacSnapEventListView: View { |
9 | 10 | let events: [WorkingSnapEvent] |
@@ -63,13 +64,19 @@ struct MacSnapEventListView: View { |
63 | 64 | } |
64 | 65 |
|
65 | 66 | HStack(alignment: .top, spacing: 10) { |
66 | | - Picker("라벨", selection: labelBinding(for: key)) { |
67 | | - ForEach(RecordingPackageLabel.allCases) { label in |
68 | | - Text(label.displayName).tag(label) |
69 | | - } |
| 67 | + HStack(spacing: 6) { |
| 68 | + Text("라벨") |
| 69 | + .font(.caption) |
| 70 | + .foregroundStyle(.secondary) |
| 71 | + .frame(width: 28, alignment: .leading) |
| 72 | + |
| 73 | + NumberShortcutMenuButton( |
| 74 | + title: currentLabel(for: event).displayName, |
| 75 | + options: labelShortcutOptions(for: key) |
| 76 | + ) |
| 77 | + .frame(width: 132) |
70 | 78 | } |
71 | | - .pickerStyle(.menu) |
72 | | - .frame(width: 130) |
| 79 | + .padding(.top, 1) |
73 | 80 |
|
74 | 81 | TextField("스냅 노트", text: notesBinding(for: key), axis: .vertical) |
75 | 82 | .textFieldStyle(.roundedBorder) |
@@ -141,22 +148,33 @@ struct MacSnapEventListView: View { |
141 | 148 | .font(.caption) |
142 | 149 | .foregroundStyle(.secondary) |
143 | 150 | } else { |
144 | | - Menu("폴더에 추가") { |
145 | | - if folders.isEmpty { |
146 | | - Text("생성된 폴더가 없습니다.") |
147 | | - } else { |
148 | | - ForEach(folders) { folder in |
149 | | - Button(folder.name) { |
150 | | - onAddToFolder(event, folder) |
151 | | - } |
152 | | - } |
153 | | - } |
154 | | - } |
| 151 | + NumberShortcutMenuButton( |
| 152 | + title: "폴더에 추가", |
| 153 | + emptyMessage: "생성된 폴더가 없습니다.", |
| 154 | + options: folderShortcutOptions(for: event) |
| 155 | + ) |
| 156 | + .frame(width: 104) |
155 | 157 | } |
156 | 158 | } |
157 | 159 | } |
158 | 160 | } |
159 | 161 |
|
| 162 | + private func labelShortcutOptions(for key: String) -> [NumberShortcutMenuOption] { |
| 163 | + RecordingPackageLabel.allCases.enumerated().map { index, label in |
| 164 | + NumberShortcutMenuOption(index: index + 1, title: label.displayName) { |
| 165 | + labelBinding(for: key).wrappedValue = label |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + private func folderShortcutOptions(for event: WorkingSnapEvent) -> [NumberShortcutMenuOption] { |
| 171 | + folders.enumerated().map { index, folder in |
| 172 | + NumberShortcutMenuOption(index: index + 1, title: folder.name) { |
| 173 | + onAddToFolder(event, folder) |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + |
160 | 178 | private func currentLabel(for event: WorkingSnapEvent) -> RecordingPackageLabel { |
161 | 179 | snapEventLabels[event.snapID]?.label ?? event.label |
162 | 180 | } |
@@ -203,3 +221,127 @@ struct MacSnapEventListView: View { |
203 | 221 | return String(format: "%.2f%@", locale: Locale(identifier: "en_US_POSIX"), value, suffix) |
204 | 222 | } |
205 | 223 | } |
| 224 | + |
| 225 | +private struct NumberShortcutMenuOption: Identifiable { |
| 226 | + let index: Int |
| 227 | + let title: String |
| 228 | + let action: () -> Void |
| 229 | + |
| 230 | + var id: Int { index } |
| 231 | + var hasShortcut: Bool { (1...9).contains(index) } |
| 232 | +} |
| 233 | + |
| 234 | +private struct NumberShortcutMenuButton: View { |
| 235 | + let title: String |
| 236 | + var emptyMessage: String = "선택할 항목이 없습니다." |
| 237 | + let options: [NumberShortcutMenuOption] |
| 238 | + |
| 239 | + @State private var isPresented = false |
| 240 | + |
| 241 | + var body: some View { |
| 242 | + Button { |
| 243 | + isPresented = true |
| 244 | + } label: { |
| 245 | + HStack(spacing: 6) { |
| 246 | + Text(title) |
| 247 | + .lineLimit(1) |
| 248 | + Image(systemName: "chevron.down") |
| 249 | + .font(.caption2) |
| 250 | + .foregroundStyle(.secondary) |
| 251 | + } |
| 252 | + .frame(maxWidth: .infinity) |
| 253 | + } |
| 254 | + .buttonStyle(.bordered) |
| 255 | + .controlSize(.small) |
| 256 | + .popover(isPresented: $isPresented, arrowEdge: .bottom) { |
| 257 | + NumberShortcutMenuContent( |
| 258 | + emptyMessage: emptyMessage, |
| 259 | + options: options, |
| 260 | + isPresented: $isPresented |
| 261 | + ) |
| 262 | + } |
| 263 | + } |
| 264 | +} |
| 265 | + |
| 266 | +private struct NumberShortcutMenuContent: View { |
| 267 | + let emptyMessage: String |
| 268 | + let options: [NumberShortcutMenuOption] |
| 269 | + @Binding var isPresented: Bool |
| 270 | + |
| 271 | + @State private var keyMonitor: Any? |
| 272 | + |
| 273 | + var body: some View { |
| 274 | + VStack(alignment: .leading, spacing: 4) { |
| 275 | + if options.isEmpty { |
| 276 | + Text(emptyMessage) |
| 277 | + .font(.callout) |
| 278 | + .foregroundStyle(.secondary) |
| 279 | + .padding(.horizontal, 10) |
| 280 | + .padding(.vertical, 8) |
| 281 | + } else { |
| 282 | + ForEach(options) { option in |
| 283 | + Button { |
| 284 | + select(option) |
| 285 | + } label: { |
| 286 | + HStack { |
| 287 | + Text(option.title) |
| 288 | + .lineLimit(1) |
| 289 | + Spacer() |
| 290 | + if option.hasShortcut { |
| 291 | + Text("\(option.index)") |
| 292 | + .font(.caption) |
| 293 | + .foregroundStyle(.secondary) |
| 294 | + } |
| 295 | + } |
| 296 | + .contentShape(Rectangle()) |
| 297 | + } |
| 298 | + .buttonStyle(.plain) |
| 299 | + .padding(.horizontal, 10) |
| 300 | + .padding(.vertical, 5) |
| 301 | + } |
| 302 | + } |
| 303 | + } |
| 304 | + .frame(minWidth: 180, alignment: .leading) |
| 305 | + .padding(.vertical, 6) |
| 306 | + .onAppear(perform: installKeyMonitor) |
| 307 | + .onDisappear(perform: removeKeyMonitor) |
| 308 | + } |
| 309 | + |
| 310 | + private func select(_ option: NumberShortcutMenuOption) { |
| 311 | + option.action() |
| 312 | + isPresented = false |
| 313 | + } |
| 314 | + |
| 315 | + private func installKeyMonitor() { |
| 316 | + removeKeyMonitor() |
| 317 | + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| 318 | + guard let number = shortcutNumber(from: event), |
| 319 | + let option = options.first(where: { $0.index == number }) else { |
| 320 | + return event |
| 321 | + } |
| 322 | + |
| 323 | + select(option) |
| 324 | + return nil |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + private func removeKeyMonitor() { |
| 329 | + if let keyMonitor { |
| 330 | + NSEvent.removeMonitor(keyMonitor) |
| 331 | + self.keyMonitor = nil |
| 332 | + } |
| 333 | + } |
| 334 | + |
| 335 | + private func shortcutNumber(from event: NSEvent) -> Int? { |
| 336 | + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) |
| 337 | + guard modifiers.isEmpty, |
| 338 | + let characters = event.charactersIgnoringModifiers, |
| 339 | + characters.count == 1, |
| 340 | + let number = Int(characters), |
| 341 | + (1...9).contains(number) else { |
| 342 | + return nil |
| 343 | + } |
| 344 | + |
| 345 | + return number |
| 346 | + } |
| 347 | +} |
0 commit comments