From 34ccd3d7592256a0a9800881f0a18b1d7b4a8ded Mon Sep 17 00:00:00 2001 From: LATE-BL00MER Date: Thu, 15 Oct 2026 13:35:59 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=EB=B0=B0=EA=B2=BD?= =?UTF-8?q?=20=EB=B6=88=ED=88=AC=EB=AA=85=20=EC=B2=98=EB=A6=AC=EC=99=80=20?= =?UTF-8?q?=ED=92=80=EC=9D=B4=20=EC=88=98=20=EC=88=AB=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Paxo/UI/PaxoStyle.swift | 51 ++++++++++---------- Paxo/UI/SettingsView.swift | 97 +++++++++++++++++++++++++++++++++----- 2 files changed, 109 insertions(+), 39 deletions(-) diff --git a/Paxo/UI/PaxoStyle.swift b/Paxo/UI/PaxoStyle.swift index 191c9af..69f6e99 100644 --- a/Paxo/UI/PaxoStyle.swift +++ b/Paxo/UI/PaxoStyle.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -/// 모든 앱 화면에서 같은 브랜드 색상과 유리 질감을 사용한다. +/// 모든 앱 화면에서 같은 브랜드 색상과 불투명 배경을 사용한다. enum PaxoStyle { static let brand = Color(red: 27 / 255, green: 118 / 255, blue: 1) static let cornerRadius: CGFloat = 14 @@ -9,7 +9,7 @@ enum PaxoStyle { extension View { func paxoSurface(cornerRadius: CGFloat = PaxoStyle.cornerRadius) -> some View { - background(PaxoGlassBackground()) + background(Color(nsColor: .windowBackgroundColor)) .overlay { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(.primary.opacity(0.1), lineWidth: 0.5) @@ -17,38 +17,35 @@ extension View { .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .tint(PaxoStyle.brand) } + + func paxoWindowSurface() -> some View { + paxoSurface(cornerRadius: 0) + .background(PaxoWindowBackground()) + .toolbarBackground(.hidden, for: .windowToolbar) + } } -private struct PaxoGlassBackground: View { - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency +/// 설정 창의 기본 제목 표시줄이 본문과 별개로 배경을 흐리지 않게 한다. +private struct PaxoWindowBackground: NSViewRepresentable { + func makeNSView(context: Context) -> WindowBackgroundView { + WindowBackgroundView() + } - var body: some View { - Group { - if reduceTransparency { - Color(nsColor: .windowBackgroundColor) - } else { - WindowGlass() - } - } - .allowsHitTesting(false) - .accessibilityHidden(true) + func updateNSView(_ view: WindowBackgroundView, context: Context) { + view.applyBackground() } } -/// 창 뒤의 배경을 흐리게 하면서 텍스트 대비를 유지한다. -private struct WindowGlass: NSViewRepresentable { - @Environment(\.colorScheme) private var colorScheme - - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .behindWindow - view.material = .hudWindow - view.state = .active - updateNSView(view, context: context) - return view +private final class WindowBackgroundView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + applyBackground() } - func updateNSView(_ view: NSVisualEffectView, context: Context) { - view.appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) + func applyBackground() { + guard let window else { return } + window.isOpaque = true + window.backgroundColor = .windowBackgroundColor + window.titlebarAppearsTransparent = true } } diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 630d7d0..6bb8ef4 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// 앱 설정과 날짜별 풀이 탐색을 하나의 설정 창에서 제공한다. @@ -5,7 +6,6 @@ struct SettingsView: View { @EnvironmentObject private var appState: AppState @EnvironmentObject private var store: StoreManager @State private var recentHistoryLimitText = "" - @FocusState private var isEditingHistoryLimit: Bool var body: some View { TabView { @@ -13,11 +13,8 @@ struct SettingsView: View { HistoryArchiveView().tabItem { Label("풀이 기록", systemImage: "clock.arrow.circlepath") } } .frame(width: 460, height: 660) - .paxoSurface(cornerRadius: 0) + .paxoWindowSurface() .onAppear { recentHistoryLimitText = String(appState.recentHistoryLimit) } - .onChange(of: isEditingHistoryLimit) { - if !isEditingHistoryLimit { saveRecentHistoryLimit() } - } .onChange(of: appState.recentHistoryLimit) { recentHistoryLimitText = String(appState.recentHistoryLimit) } @@ -135,15 +132,9 @@ struct SettingsView: View { Text("해설과 함께 표시").tag(true) } LabeledContent("메뉴에 표시할 풀이 수") { - TextField("", text: $recentHistoryLimitText) - .labelsHidden() - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) + HistoryLimitField(text: $recentHistoryLimitText, onCommit: saveRecentHistoryLimit) .frame(width: 60) - .focused($isEditingHistoryLimit) - .onSubmit { saveRecentHistoryLimit() } .onDisappear { saveRecentHistoryLimit() } - .accessibilityLabel("메뉴에 표시할 풀이 수") } .disabled(!appState.showsRecentHistory) Text("메뉴에는 최신 풀이를 설정한 개수만큼 표시합니다. 이전 풀이는 풀이 기록 탭에서 볼 수 있습니다. 정답만 표시해도 저장된 해설은 기록을 열어 볼 수 있습니다.") @@ -276,3 +267,85 @@ struct SettingsView: View { recentHistoryLimitText = String(appState.recentHistoryLimit) } } + +/// AppKit의 편집 검증을 사용해 문자 입력과 붙여넣기를 같은 기준으로 차단한다. +private struct HistoryLimitField: NSViewRepresentable { + @Binding var text: String + @Environment(\.isEnabled) private var isEnabled + let onCommit: () -> Void + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField(string: text) + field.formatter = HistoryLimitFormatter() + field.delegate = context.coordinator + field.bezelStyle = .roundedBezel + field.alignment = .right + field.font = .systemFont(ofSize: NSFont.systemFontSize) + field.setAccessibilityLabel("메뉴에 표시할 풀이 수") + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + context.coordinator.parent = self + field.isEnabled = isEnabled + if field.stringValue != text { field.stringValue = text } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: HistoryLimitField + + init(parent: HistoryLimitField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func controlTextDidEndEditing(_ notification: Notification) { + parent.onCommit() + } + + func control( + _ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector + ) -> Bool { + guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } + parent.onCommit() + return true + } + } +} + +/// 저장 시점까지 잘못된 문자가 남지 않도록 편집 중에도 숫자만 허용한다. +private final class HistoryLimitFormatter: Formatter { + override func string(for object: Any?) -> String? { + object as? String + } + + override func getObjectValue( + _ object: AutoreleasingUnsafeMutablePointer?, + for string: String, + errorDescription error: AutoreleasingUnsafeMutablePointer? + ) -> Bool { + guard isValid(string) else { return false } + object?.pointee = string as NSString + return true + } + + override func isPartialStringValid( + _ partialString: String, + newEditingString newString: AutoreleasingUnsafeMutablePointer?, + errorDescription error: AutoreleasingUnsafeMutablePointer? + ) -> Bool { + isValid(partialString) + } + + private func isValid(_ string: String) -> Bool { + string.utf8.allSatisfy { (48...57).contains($0) } + } +}