Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 24 additions & 27 deletions Paxo/UI/PaxoStyle.swift
Original file line number Diff line number Diff line change
@@ -1,54 +1,51 @@
import AppKit
import SwiftUI

/// 모든 앱 화면에서 같은 브랜드 색상과 유리 질감을 사용한다.
/// 모든 앱 화면에서 같은 브랜드 색상과 불투명 배경을 사용한다.
enum PaxoStyle {
static let brand = Color(red: 27 / 255, green: 118 / 255, blue: 1)
static let cornerRadius: CGFloat = 14
}

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)
}
.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
}
}
97 changes: 85 additions & 12 deletions Paxo/UI/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import AppKit
import SwiftUI

/// 앱 설정과 날짜별 풀이 탐색을 하나의 설정 창에서 제공한다.
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 {
generalSettings.tabItem { Label("설정", systemImage: "gearshape") }
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)
}
Expand Down Expand Up @@ -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("메뉴에는 최신 풀이를 설정한 개수만큼 표시합니다. 이전 풀이는 풀이 기록 탭에서 볼 수 있습니다. 정답만 표시해도 저장된 해설은 기록을 열어 볼 수 있습니다.")
Expand Down Expand Up @@ -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<AnyObject?>?,
for string: String,
errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?
) -> Bool {
guard isValid(string) else { return false }
object?.pointee = string as NSString
return true
}

override func isPartialStringValid(
_ partialString: String,
newEditingString newString: AutoreleasingUnsafeMutablePointer<NSString?>?,
errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?
) -> Bool {
isValid(partialString)
}

private func isValid(_ string: String) -> Bool {
string.utf8.allSatisfy { (48...57).contains($0) }
}
}
Loading