From c383281d9a4dc233502a7e29ffc26bbf8f48df79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Fri, 25 Sep 2026 14:58:38 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=ED=95=B4=EC=84=A4=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=B4=20=EA=B8=B0=EB=A1=9D=EC=9D=98=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EC=85=8B=EC=9D=84=20=EC=93=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기록에서 해설을 다시 요청하면 현재 설정의 프리셋이 들어가 힌트가 어긋났다 --- Paxo/AppState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index d856963..b90c445 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -298,7 +298,7 @@ final class AppState: ObservableObject { phase = .solvingExplanation do { let explanation = try await makeService() - .explain(imageData: image, answer: answer, preset: preset) + .explain(imageData: image, answer: answer, preset: current.preset) self.current?.explanation = explanation phase = .done upsertHistory() From d4357c7b872ba6d494e5c60ce4b50557aa18a521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Fri, 25 Sep 2026 15:02:31 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9D=91=EB=8B=B5=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EB=B3=84=20=EC=95=88=EB=82=B4=EC=99=80=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기다린 시간에 따라 문구·아이콘이 바뀐다 (읽는 중 → 떠올리는 중 → 거의 다 됨 → 오래 걸림) - 정답 대기 중 캡처 미리보기, 해설 대기 중 스켈레톤을 보여준다 - 15초가 넘으면 안내와 취소 버튼을 연다 (패널·토스트) - 요청 소유권(SolveRequestGate)으로 취소·기록 전환 뒤 도착한 늦은 응답을 버린다 - 정답 확정 전 취소는 무료 횟수를 차감하지 않는다. 확정 후 취소는 해설에만 적용된다 - URLError.cancelled를 네트워크 오류가 아닌 취소로 분류한다 - 해설 요청 연타를 무시하고, 오래된 토스트 타이머가 새 화면을 지우지 않게 한다 --- Paxo/AI/GeminiService.swift | 7 +- Paxo/AppState.swift | 124 +++++++++++++++++--- Paxo/Models.swift | 53 +++++++++ Paxo/SolveRequestGate.swift | 35 ++++++ Paxo/UI/ResultView.swift | 14 +-- Paxo/UI/ToastController.swift | 7 +- Paxo/UI/WaitingView.swift | 161 ++++++++++++++++++++++++++ PaxoTests/GeminiServiceTests.swift | 15 +++ PaxoTests/SolveRequestGateTests.swift | 47 ++++++++ PaxoTests/WaitingStageTests.swift | 44 +++++++ 10 files changed, 471 insertions(+), 36 deletions(-) create mode 100644 Paxo/SolveRequestGate.swift create mode 100644 Paxo/UI/WaitingView.swift create mode 100644 PaxoTests/SolveRequestGateTests.swift create mode 100644 PaxoTests/WaitingStageTests.swift diff --git a/Paxo/AI/GeminiService.swift b/Paxo/AI/GeminiService.swift index 22784d5..8a9da3f 100644 --- a/Paxo/AI/GeminiService.swift +++ b/Paxo/AI/GeminiService.swift @@ -100,7 +100,7 @@ struct GeminiService { do { (data, response) = try await Self.session.data(for: request) } catch let error as URLError { - throw GeminiError.network(error) + throw Self.classify(error) } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { @@ -127,6 +127,11 @@ struct GeminiService { return text } + /// 사용자가 취소한 요청은 네트워크 오류가 아니다. 오류 화면으로 보내지 않도록 취소로 구분한다. + static func classify(_ error: URLError) -> Error { + error.code == .cancelled ? CancellationError() : GeminiError.network(error) + } + private static func serverMessage(from data: Data) -> String? { struct ErrorEnvelope: Decodable { struct Inner: Decodable { let message: String? } diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index b90c445..55f5f98 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import ImageIO import ServiceManagement @MainActor @@ -20,6 +21,8 @@ final class AppState: ObservableObject { @Published private(set) var phase: SolvePhase = .idle @Published private(set) var current: SolveResult? @Published private(set) var history: [SolveResult] = [] + /// 지금 기다리는 요청이 시작된 시각. 대기 화면이 경과 시간으로 안내 단계를 고른다. + @Published private(set) var waitingSince: Date? // MARK: - 설정 @@ -117,6 +120,11 @@ final class AppState: ObservableObject { return imageCache[current.id] != nil } + /// 대기 화면에 보여줄 캡처 미리보기. 결과 ID로 찾으므로 기록을 바꾸면 따라 바뀐다. + var currentThumbnail: NSImage? { + current.flatMap { thumbnailCache[$0.id] } + } + private let hotkeyManager = HotkeyManager() private let capturer = ScreenCapturer() private let resultPanel = ResultPanelController() @@ -127,7 +135,10 @@ final class AppState: ObservableObject { private let usage = UsageTracker() /// 세션 내 캡처 이미지 캐시 (SolveResult.id 키). 해설 재생성용. 디스크 저장 안 함. private var imageCache: [UUID: Data] = [:] + private var thumbnailCache: [UUID: NSImage] = [:] private var imageCacheOrder: [UUID] = [] + private let requestGate = SolveRequestGate() + private var activeTask: Task? private static let imageCacheLimit = 8 /// 마지막으로 캡처한 화면 — 결과 패널/토스트를 같은 화면에 띄우기 위함 private var lastCaptureScreen: NSScreen? @@ -202,7 +213,7 @@ final class AppState: ObservableObject { return } phase = .capturing - Task { await runSolve() } + activeTask = Task { await runSolve() } } func showPaywall() { @@ -217,6 +228,8 @@ final class AppState: ObservableObject { func showFromHistory(_ item: SolveResult) { // 기록 다시 보기는 항상 패널로 (해설 확인·재생성 가능). // 같은 세션에 캡처 이미지가 캐시에 남아 있으면 "해설 보기"로 재생성 가능. + // 진행 중인 요청은 먼저 끊는다. 두면 늦은 응답이 방금 연 기록을 덮어쓴다. + cancelSolve() toastDismissTask?.cancel() toast.dismiss() current = item @@ -225,25 +238,64 @@ final class AppState: ObservableObject { } func requestExplanation() { - Task { await runExplanation() } + // 이미 날아가는 요청이 있으면 연타를 무시한다 + guard !requestGate.isActive else { return } + let requestID = requestGate.begin() + activeTask = Task { await runExplanation(requestID: requestID) } + } + + /// 대기 중인 요청을 취소한다. + /// 정답 대기 중이면 풀이 자체를 접는다 — 정답을 확정하지 않았으므로 무료 횟수는 그대로다. + /// 해설 대기 중이면 정답은 남기고 "해설 보기"로 다시 요청할 수 있게 둔다. + func cancelSolve() { + guard requestGate.isActive else { return } + requestGate.invalidate() + activeTask?.cancel() + activeTask = nil + waitingSince = nil + switch phase { + case .solvingAnswer: + phase = .idle + resultPanel.hide() + toast.dismiss() + case .solvingExplanation: + phase = .answerReady + default: + break + } } private func runSolve() async { toastDismissTask?.cancel() + let capture: ScreenCapturer.Capture do { - guard let capture = try await capturer.captureInteractive(mode: captureMode) else { + guard let captured = try await capturer.captureInteractive(mode: captureMode) else { phase = .idle return } - lastCaptureScreen = capture.screen - let result = SolveResult(preset: preset) - cacheImage(capture.data, for: result.id) - current = result - phase = .solvingAnswer - presentSolving() + capture = captured + } catch { + toast.dismiss() + phase = .failedAnswer(errorMessage(from: error)) + resultPanel.show(appState: self, on: lastCaptureScreen) + return + } + + lastCaptureScreen = capture.screen + let result = SolveResult(preset: preset) + cacheImage(capture.data, for: result.id) + current = result + let requestID = requestGate.begin() + waitingSince = Date() + phase = .solvingAnswer + presentSolving() + do { let answer = try await makeService() - .answer(imageData: capture.data, preset: preset) + .answer(imageData: capture.data, preset: result.preset) + // 취소가 먼저 처리됐으면 응답을 버린다. 정답을 확정하지 않았으니 차감도 하지 않는다. + guard requestGate.isCurrent(requestID) else { return } + waitingSince = nil current?.answer = answer phase = .answerReady upsertHistory() @@ -255,13 +307,24 @@ final class AppState: ObservableObject { } if resultDisplayMode == .toast { + requestGate.finish(requestID) toast.show(appState: self, on: lastCaptureScreen) scheduleToastDismiss() } else if !quickCheckMode { - await runExplanation() + await runExplanation(requestID: requestID) + } else { + requestGate.finish(requestID) } } catch { + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil toast.dismiss() + if error is CancellationError { + phase = .idle + resultPanel.hide() + return + } phase = .failedAnswer(errorMessage(from: error)) resultPanel.show(appState: self, on: lastCaptureScreen) } @@ -281,41 +344,68 @@ final class AppState: ObservableObject { private func scheduleToastDismiss() { toastDismissTask?.cancel() let seconds = toastDuration + let resultID = current?.id toastDismissTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - guard !Task.isCancelled else { return } - self?.toast.dismiss() - self?.phase = .idle + // 그 사이 새 풀이가 시작됐으면 오래된 타이머가 새 화면을 지우지 않게 한다 + guard !Task.isCancelled, let self, self.current?.id == resultID else { return } + self.toast.dismiss() + self.phase = .idle } } - private func runExplanation() async { + private func runExplanation(requestID: UUID) async { guard let current, let image = imageCache[current.id], let answer = current.answer, current.explanation == nil - else { return } + else { + requestGate.finish(requestID) + return + } + waitingSince = Date() phase = .solvingExplanation do { let explanation = try await makeService() .explain(imageData: image, answer: answer, preset: current.preset) + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil + guard self.current?.id == current.id else { return } self.current?.explanation = explanation phase = .done upsertHistory() } catch { - phase = .failedExplanation(errorMessage(from: error)) + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) + waitingSince = nil + phase = error is CancellationError ? .answerReady : .failedExplanation(errorMessage(from: error)) } } private func cacheImage(_ data: Data, for id: UUID) { imageCache[id] = data + thumbnailCache[id] = Self.makeThumbnail(from: data) imageCacheOrder.append(id) while imageCacheOrder.count > Self.imageCacheLimit { let old = imageCacheOrder.removeFirst() imageCache[old] = nil + thumbnailCache[old] = nil } } + /// 대기 화면용 작은 미리보기. 원본(최대 2000px)을 그대로 쥐면 캐시 8장이 메모리를 크게 먹는다. + private static func makeThumbnail(from data: Data) -> NSImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 240, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + } + static func screenUnderMouse() -> NSScreen? { let location = NSEvent.mouseLocation return NSScreen.screens.first { $0.frame.contains(location) } diff --git a/Paxo/Models.swift b/Paxo/Models.swift index a839868..ed4659b 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -111,3 +111,56 @@ struct SolveResult: Identifiable, Codable, Equatable { self.preset = preset } } + +/// 응답을 기다리는 동안의 안내 단계. +/// 스피너만 돌면 멈춘 것처럼 느껴져서, 기다린 시간에 맞춰 문구를 바꾸고 오래 걸리면 취소를 연다. +enum WaitingStage: CaseIterable, Equatable { + case reading + case thinking + case finishing + case slow + case explaining + + /// 이 시간을 넘기면 오래 걸린다고 알리고 취소 버튼을 보여준다 + static let slowThreshold: TimeInterval = 15 + + /// 정답 대기. 시계가 뒤로 가서 음수가 나와도 첫 단계로 본다. + static func answerStage(elapsed: TimeInterval) -> WaitingStage { + switch elapsed { + case ..<3: return .reading + case ..<8: return .thinking + case .. WaitingStage { + elapsed < slowThreshold ? .explaining : .slow + } + + var message: String { + switch self { + case .reading: return "문제를 읽고 있어요" + case .thinking: return "풀이를 떠올리는 중이에요" + case .finishing: return "거의 다 됐어요" + case .slow: return "조금 오래 걸리고 있어요" + case .explaining: return "해설을 정리하고 있어요" + } + } + + var detail: String? { + self == .slow ? "복잡한 문제일수록 시간이 더 걸려요." : nil + } + + var symbolName: String { + switch self { + case .reading: return "eye" + case .thinking: return "lightbulb" + case .finishing: return "pencil" + case .slow: return "hourglass" + case .explaining: return "book" + } + } + + var allowsCancel: Bool { self == .slow } +} diff --git a/Paxo/SolveRequestGate.swift b/Paxo/SolveRequestGate.swift new file mode 100644 index 0000000..857289f --- /dev/null +++ b/Paxo/SolveRequestGate.swift @@ -0,0 +1,35 @@ +import Foundation + +/// 진행 중인 AI 요청의 소유권. +/// +/// 취소하거나 기록으로 화면을 바꾼 뒤에 도착한 이전 응답이 화면 · 기록 · 무료 사용량을 바꾸지 못하게 한다. +/// 모든 `await` 직후 `isCurrent`로 확인한다. 정답 확정과 취소는 MainActor에서 먼저 처리된 쪽이 이긴다. +@MainActor +final class SolveRequestGate { + private var currentID: UUID? + + var isActive: Bool { currentID != nil } + + /// 새 요청을 시작한다. 이전 요청은 이 시점부터 무효다. + func begin() -> UUID { + let id = UUID() + currentID = id + return id + } + + func isCurrent(_ id: UUID) -> Bool { + currentID == id + } + + /// 요청이 끝났을 때 부른다. 늦게 끝난 이전 작업이 새 요청을 지우지 않도록 ID가 같을 때만 비운다. + func finish(_ id: UUID) { + if currentID == id { + currentID = nil + } + } + + /// 사용자가 취소했다. 지금 날아가는 응답은 도착해도 버려진다. + func invalidate() { + currentID = nil + } +} diff --git a/Paxo/UI/ResultView.swift b/Paxo/UI/ResultView.swift index 2a19bec..2675258 100644 --- a/Paxo/UI/ResultView.swift +++ b/Paxo/UI/ResultView.swift @@ -26,7 +26,7 @@ struct ResultView: View { : "풀이할 영역을 드래그하세요. (Esc 취소)" ) case .solvingAnswer: - loading("문제를 푸는 중…") + AnswerWaitingView() case .answerReady, .solvingExplanation, .done, .failedExplanation: // 해설만 실패한 경우에도 정답은 보존해서 보여준다 resultBody @@ -96,7 +96,7 @@ struct ResultView: View { } } } else if appState.phase == .solvingExplanation { - loading("해설을 작성하는 중…") + ExplanationWaitingView() } else if case .failedExplanation(let message) = appState.phase { // 정답은 위에 그대로 있고, 해설만 재시도 VStack(alignment: .leading, spacing: 6) { @@ -143,14 +143,4 @@ struct ResultView: View { .font(.callout) .foregroundStyle(.secondary) } - - private func loading(_ text: String) -> some View { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } - } } diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index 052f117..983368a 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -95,12 +95,7 @@ private struct ToastView: View { private var content: some View { switch appState.phase { case .capturing, .solvingAnswer: - HStack(spacing: 10) { - ProgressView().controlSize(.small) - Text("푸는 중…") - .font(.title3) - .foregroundStyle(.secondary) - } + ToastWaitingView() default: if let answer = appState.current?.answer { Text(answer) diff --git a/Paxo/UI/WaitingView.swift b/Paxo/UI/WaitingView.swift new file mode 100644 index 0000000..11ee7c0 --- /dev/null +++ b/Paxo/UI/WaitingView.swift @@ -0,0 +1,161 @@ +import AppKit +import SwiftUI + +/// 정답을 기다리는 동안 결과 패널에 보여주는 화면. +/// 무엇을 캡처했는지 먼저 보여주고, 기다린 시간에 맞춰 안내 문구를 바꾼다. +struct AnswerWaitingView: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if let thumbnail = appState.currentThumbnail { + CaptureThumbnailCard(image: thumbnail, preset: appState.current?.preset ?? appState.preset) + } + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage in + WaitingStageRow(stage: stage) + } + } + } +} + +/// 정답은 이미 보이고 해설만 기다릴 때. 해설이 들어설 자리를 미리 잡아 두어 화면이 튀지 않게 한다. +struct ExplanationWaitingView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var dimmed = false + + var body: some View { + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.explanationStage) { stage in + VStack(alignment: .leading, spacing: 10) { + WaitingStageRow(stage: stage) + VStack(alignment: .leading, spacing: 7) { + skeletonLine(width: 290) + skeletonLine(width: 240) + skeletonLine(width: 265) + } + .opacity(dimmed ? 0.45 : 1) + } + } + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + dimmed = true + } + } + } + + private func skeletonLine(width: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(.quaternary) + .frame(maxWidth: width, minHeight: 9, maxHeight: 9) + } +} + +/// 토스트용 한 줄 대기 표시. +/// 토스트는 뜰 때 한 번만 크기를 재므로, 가장 긴 단계(문구 + 취소 버튼)가 들어가는 고정 폭을 쓴다. +struct ToastWaitingView: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage in + WaitingStageRow(stage: stage, compact: true) + } + .frame(width: 320, alignment: .leading) + } +} + +/// 단계 아이콘 · 문구 · (오래 걸리면) 취소 버튼 +struct WaitingStageRow: View { + @EnvironmentObject private var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let stage: WaitingStage + var compact = false + + var body: some View { + Group { + if compact { + HStack(spacing: 10) { + icon.font(.title3) + Text(stage.message) + .font(.title3) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + if stage.allowsCancel { cancelButton } + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + icon.font(.title2) + Text(stage.message) + .font(.callout.weight(.medium)) + } + if let detail = stage.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + if stage.allowsCancel { cancelButton } + } + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: stage) + } + + private var icon: some View { + Image(systemName: stage.symbolName) + .foregroundStyle(.tint) + .symbolEffect(.pulse, isActive: !reduceMotion) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 28) + } + + private var cancelButton: some View { + Button("취소") { + appState.cancelSolve() + } + .controlSize(.small) + } +} + +/// 1초마다 경과 시간을 다시 계산해 단계를 넘긴다. 틱 수가 아니라 시작 시각 기준이라 갱신이 밀려도 어긋나지 않는다. +private struct WaitingTimeline: View { + let since: Date? + let stage: (TimeInterval) -> WaitingStage + @ViewBuilder let content: (WaitingStage) -> Content + + var body: some View { + TimelineView(.periodic(from: since ?? .now, by: 1)) { context in + content(stage(context.date.timeIntervalSince(since ?? context.date))) + } + } +} + +/// "이 문제를 보고 있어요"를 눈으로 확인시켜 주는 캡처 미리보기 +private struct CaptureThumbnailCard: View { + let image: NSImage + let preset: SubjectPreset + + var body: some View { + HStack(spacing: 10) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: 72, maxHeight: 48) + .clipShape(RoundedRectangle(cornerRadius: 4)) + VStack(alignment: .leading, spacing: 2) { + Text("캡처한 문제") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(preset.displayName) · 방금") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 0.5) + ) + } +} diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift index a83d3f1..59cd287 100644 --- a/PaxoTests/GeminiServiceTests.swift +++ b/PaxoTests/GeminiServiceTests.swift @@ -22,6 +22,21 @@ struct GeminiServiceTests { #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == nil) } + /// 취소를 네트워크 오류로 감싸면 사용자가 누른 취소가 "네트워크 오류" 화면으로 보인다. + @Test func 취소된_요청은_취소로_분류된다() { + let error = GeminiService.classify(URLError(.cancelled)) + #expect(error is CancellationError) + } + + @Test func 다른_네트워크_오류는_그대로_네트워크_오류다() { + let error = GeminiService.classify(URLError(.timedOut)) + guard case GeminiError.network(let underlying)? = error as? GeminiError else { + Issue.record("GeminiError.network가 아니다: \(error)") + return + } + #expect(underlying.code == .timedOut) + } + #if DEBUG @Test func 직접_호출은_프록시를_우회하고_Gemini_키를_사용한다() throws { let service = GeminiService( diff --git a/PaxoTests/SolveRequestGateTests.swift b/PaxoTests/SolveRequestGateTests.swift new file mode 100644 index 0000000..6d2eb4c --- /dev/null +++ b/PaxoTests/SolveRequestGateTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 게이트가 늦은 응답을 통과시키면 취소한 풀이가 무료 횟수를 깎거나 다른 문제의 결과를 덮어쓴다. +@MainActor +struct SolveRequestGateTests { + @Test func 새_요청을_시작하면_이전_요청은_무효가_된다() { + let gate = SolveRequestGate() + let first = gate.begin() + let second = gate.begin() + + #expect(!gate.isCurrent(first)) + #expect(gate.isCurrent(second)) + } + + @Test func 취소하면_날아가던_응답은_버려진다() { + let gate = SolveRequestGate() + let id = gate.begin() + + gate.invalidate() + + #expect(!gate.isCurrent(id)) + #expect(!gate.isActive) + } + + @Test func 늦게_끝난_이전_요청은_새_요청을_지우지_못한다() { + let gate = SolveRequestGate() + let old = gate.begin() + let new = gate.begin() + + gate.finish(old) + + #expect(gate.isCurrent(new)) + #expect(gate.isActive) + } + + @Test func 자기_요청을_끝내면_비활성이_된다() { + let gate = SolveRequestGate() + let id = gate.begin() + + gate.finish(id) + + #expect(!gate.isActive) + } +} diff --git a/PaxoTests/WaitingStageTests.swift b/PaxoTests/WaitingStageTests.swift new file mode 100644 index 0000000..0031484 --- /dev/null +++ b/PaxoTests/WaitingStageTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 단계 경계가 조용히 바뀌면 "멈춘 것 같은" 대기 화면이 되거나 취소 버튼이 너무 늦게 뜬다. +struct WaitingStageTests { + @Test(arguments: [ + (-1.0, WaitingStage.reading), + (0, .reading), + (2.9, .reading), + (3, .thinking), + (7.9, .thinking), + (8, .finishing), + (14.9, .finishing), + (15, .slow), + (60, .slow), + ]) + func 정답_대기는_경과_시간에_따라_단계가_바뀐다(elapsed: TimeInterval, expected: WaitingStage) { + #expect(WaitingStage.answerStage(elapsed: elapsed) == expected) + } + + @Test(arguments: [ + (0.0, WaitingStage.explaining), + (14.9, .explaining), + (15, .slow), + (90, .slow), + ]) + func 해설_대기는_오래_걸릴_때만_느림_단계가_된다(elapsed: TimeInterval, expected: WaitingStage) { + #expect(WaitingStage.explanationStage(elapsed: elapsed) == expected) + } + + @Test(arguments: WaitingStage.allCases) + func 모든_단계에_문구와_아이콘이_있다(stage: WaitingStage) { + #expect(!stage.message.isEmpty) + #expect(!stage.symbolName.isEmpty) + } + + /// 취소는 오래 걸릴 때만 연다. 처음부터 보이면 누르기 쉬워 멀쩡한 요청이 끊긴다. + @Test(arguments: WaitingStage.allCases) + func 취소는_느림_단계에서만_보인다(stage: WaitingStage) { + #expect(stage.allowsCancel == (stage == .slow)) + } +} From 1f42eb572435fa4583971d1baeb3ebe67ceaf404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Fri, 25 Sep 2026 15:04:57 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20API=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EC=A4=80=EC=84=A0=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정답 → 해설 2단계 요청을 앱과 같은 프롬프트로 재현해 구간별 시간을 CSV로 남긴다 - 같은 연결에서 GET으로 먼저 핸드셰이크하는 예열 실험을 함께 잰다 - 토큰은 헤더 파일로 넘겨 프로세스 목록과 결과물에 남지 않게 한다 - 측정용 문제 이미지는 직접 만든 것이라 저작권 문제가 없다 --- .gitignore | 3 + scripts/fixtures/sample-problem.jpg | Bin 0 -> 30767 bytes scripts/measure-latency.sh | 139 ++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 scripts/fixtures/sample-problem.jpg create mode 100755 scripts/measure-latency.sh diff --git a/.gitignore b/.gitignore index f87574a..2c1af07 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ docs/private # AI 도구 로컬 설정 (팀 공유는 .claude/settings.json) .claude/settings.local.json CLAUDE.local.md + +# scripts/measure-latency.sh 결과 (원자료는 PR에 요약만) +latency-*.csv diff --git a/scripts/fixtures/sample-problem.jpg b/scripts/fixtures/sample-problem.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6901d0e91b3b71732b540baea4f15e1202ca3eed GIT binary patch literal 30767 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 M%iqNf=g;Z?07^%k&Hw-a literal 0 HcmV?d00001 diff --git a/scripts/measure-latency.sh b/scripts/measure-latency.sh new file mode 100755 index 0000000..3eacc53 --- /dev/null +++ b/scripts/measure-latency.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# 풀이 속도 개선 전에 "지금 어디서 얼마나 걸리는지"를 숫자로 남기기 위한 API 기준선 측정. +# 앱에는 로깅을 넣지 않기로 했으므로(AGENTS.md) 프록시를 직접 호출해 잰다. +# 캡처 · 인코딩 · 화면 렌더링 시간은 포함되지 않는다. +# +# 측정 항목 +# cold : 새 연결로 정답 요청 → 받은 정답을 넣어 해설 요청 (앱과 같은 2단계) +# warm : 같은 연결에서 GET으로 먼저 핸드셰이크 → 정답 요청 (연결 예열 실험) +# GET은 프록시가 토큰 · 사용량 검사 전에 405로 돌려보내므로 Gemini를 호출하지 않는다. +# +# 주의 +# - 현재 프록시는 Gemini 응답을 전부 받은 뒤 보내므로 TTFB는 "첫 토큰" 시간이 아니다. +# - 실제 Gemini를 호출한다. 기본 5회 = POST 15회. 프록시 기기당 하루 60회(UTC, best-effort) 안에서 쓴다. +# - 반복 5회의 p90은 탐색용이다. 원자료 CSV를 함께 본다. +# +# 사용법 +# PAXO_PROXY_URL=https://api.paxo.co.kr PAXO_TOKEN=... scripts/measure-latency.sh [반복 횟수] +# 결과: 현재 디렉터리의 latency-<시각>.csv (토큰은 기록하지 않는다. 커밋하지 않는다) +set -euo pipefail + +: "${PAXO_PROXY_URL:?PAXO_PROXY_URL을 지정하세요}" +: "${PAXO_TOKEN:?PAXO_TOKEN을 지정하세요}" + +RUNS="${1:-5}" +BASE="${PAXO_PROXY_URL%/}" +URL="$BASE/generate" +# 실제 사용자와 섞이지 않도록 측정 전용 고정 기기 ID를 쓴다 +DEVICE="00000000-0000-4000-8000-00000000a11c" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="$ROOT/scripts/fixtures/sample-problem.jpg" +OUT="$PWD/latency-$(date +%Y%m%d-%H%M%S).csv" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +chmod 700 "$WORK" + +# 토큰이 프로세스 목록(ps)에 드러나지 않도록 헤더는 파일로 넘긴다 +HEADERS="$WORK/headers" +printf 'content-type: application/json\nx-paxo-device: %s\nx-paxo-token: %s\n' "$DEVICE" "$PAXO_TOKEN" >"$HEADERS" +chmod 600 "$HEADERS" + +# 프롬프트는 Paxo/AI/Prompts.swift(일반 프리셋)와 같게 유지한다. 바뀌면 여기도 고친다. +build_body() { + python3 - "$IMAGE" "$1" "${2:-}" <<'PY' +import base64, json, sys +image, kind, answer = sys.argv[1], sys.argv[2], sys.argv[3] +if kind == "answer": + prompt = ( + "당신은 한국어 학습 도우미입니다. 이미지에 있는 문제를 정확히 풀어주세요.\n\n" + "출력 형식: 정답만 한 줄로 간결하게 출력하세요. 풀이 과정은 다음 단계에서 별도로 요청됩니다.\n" + "- 객관식이면 번호만 (예: ③)\n" + "- 단답형이면 답만\n" + "- 구해야 하는 답이 여러 개면 순서대로 쉼표로 구분 (예: 첫번째답, 두번째답)" + ) +else: + prompt = ( + f"당신은 친절한 한국어 과외 선생님입니다. 이미지에 있는 문제의 정답은 \"{answer}\"입니다.\n\n" + "학생이 이해할 수 있도록 왜 이 답이 되는지 설명해주세요:\n" + "1. 문제가 묻는 핵심 개념\n" + "2. 단계별 풀이 과정\n" + "3. 헷갈리기 쉬운 포인트 (오답 선택지가 있다면 왜 틀렸는지)\n" + "간결하되 핵심이 빠지지 않게, 읽기 쉬운 짧은 문단으로 작성하세요. " + "간단한 마크다운(굵게, 목록)을 사용해도 됩니다." + ) +with open(image, "rb") as f: + data = base64.b64encode(f.read()).decode() +body = {"contents": [{"parts": [{"text": prompt}, {"inline_data": {"mime_type": "image/jpeg", "data": data}}]}]} +print(json.dumps(body, ensure_ascii=False)) +PY +} + +extract_text() { + python3 -c ' +import json, sys +try: + parts = json.load(open(sys.argv[1]))["candidates"][0]["content"]["parts"] + print("\n".join(p.get("text", "") for p in parts).strip()) +except Exception: + print("") +' "$1" +} + +FORMAT='%{http_code},%{http_version},%{num_connects},%{time_namelookup},%{time_connect},%{time_appconnect},%{time_starttransfer},%{time_total},%{size_upload},%{size_download}\n' +echo "run,mode,request,http_code,http_version,new_connections,dns_s,tcp_s,tls_s,ttfb_s,total_s,bytes_up,bytes_down" >"$OUT" + +build_body answer >"$WORK/answer.json" +echo "측정 대상: $BASE · 반복 ${RUNS}회 · 예상 POST $((RUNS * 3))회" + +for run in $(seq 1 "$RUNS"); do + # cold: 새 프로세스 = 새 연결 + line=$(curl -sS -o "$WORK/answer.out" -w "$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/answer.json" "$URL") + echo "$run,cold,answer,$line" >>"$OUT" + answer="$(extract_text "$WORK/answer.out")" + if [ -z "$answer" ]; then + echo " #$run 정답 요청 실패 ($(cut -d, -f1 <<<"$line")) — 해설 측정 건너뜀" + else + build_body explanation "$answer" >"$WORK/explanation.json" + line=$(curl -sS -o "$WORK/explanation.out" -w "$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/explanation.json" "$URL") + echo "$run,cold,explanation,$line" >>"$OUT" + fi + + # warm: 같은 curl 실행 안에서 GET으로 연결을 맺고 그 연결로 정답 요청 + curl -sS -o /dev/null -w "GET,$FORMAT" "$URL" \ + --next -o /dev/null -w "POST,$FORMAT" -H @"$HEADERS" --data-binary @"$WORK/answer.json" "$URL" \ + | while IFS= read -r row; do + kind="${row%%,*}" + rest="${row#*,}" + [ "$kind" = "GET" ] && echo "$run,warm,prewarm_get,$rest" >>"$OUT" + [ "$kind" = "POST" ] && echo "$run,warm,answer,$rest" >>"$OUT" + done + echo " #$run 완료 (정답: ${answer:-없음})" +done + +echo +python3 - "$OUT" <<'PY' +import csv, statistics, sys +rows = list(csv.DictReader(open(sys.argv[1]))) +def pct(values, p): + values = sorted(values) + if not values: + return float("nan") + k = (len(values) - 1) * p + lo, hi = int(k), min(int(k) + 1, len(values) - 1) + return values[lo] + (values[hi] - values[lo]) * (k - lo) +def sec(value, digits): + return "-" if value != value else f"{value:.{digits}f}s" +print(f"{'구분':<22}{'성공/전체':>10}{'새 연결':>8}{'TLS까지 p50':>13}{'총 p50':>9}{'총 p90':>9}") +for mode, req in [("cold", "answer"), ("warm", "answer"), ("cold", "explanation"), ("warm", "prewarm_get")]: + group = [r for r in rows if r["mode"] == mode and r["request"] == req] + ok = [r for r in group if r["http_code"] in ("200", "405")] + total = [float(r["total_s"]) for r in ok] + tls = [float(r["tls_s"]) for r in ok] + conns = sum(int(r["new_connections"]) for r in group) + print( + f"{mode + ' ' + req:<22}{len(ok):>6}/{len(group):<3}{conns:>8}" + f"{sec(pct(tls, .5), 3):>13}{sec(pct(total, .5), 2):>9}{sec(pct(total, .9), 2):>9}" + ) +print("\nwarm answer의 새 연결이 0이면 예열 연결을 재사용한 것이다. 원자료:", sys.argv[1]) +PY From 1302901b669c6ec912512b40eda24e6044b5dea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Fri, 25 Sep 2026 15:05:14 +0900 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20=EB=8C=80=EA=B8=B0=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=C2=B7=EC=B7=A8=EC=86=8C=20=EA=B3=BC=EA=B8=88=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=C2=B7=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B6=84=ED=95=A0=EC=9D=84=20=EA=B2=B0=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=A0=9C=EC=95=88=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 1 + docs/decisions.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 2cac371..f1ab863 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ - 정답과 해설은 각각 별도의 API 요청으로 처리한다. - 무료 횟수는 '정답 요청'이 성공했을 때만 차감한다. 해설 요청 실패 시에는 추가로 차감하지 않는다. - 토스트 모드에서는 해설을 요청하지 않는다. +- 기다리는 동안 15초가 지나면 취소할 수 있다. 정답 확정 전에 취소하면 무료 횟수를 차감하지 않으며, 취소나 기록 전환 뒤에 도착한 응답은 `SolveRequestGate`가 버린다. ## 앱 ↔ 프록시 API 스펙 (Contract) diff --git a/docs/decisions.md b/docs/decisions.md index de91249..f2e059d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,3 +12,6 @@ | 2026-09-13 | 저장소를 공개로 전환했다 | GitHub Actions 무료 · 보호 규칙 · 시크릿 스캔을 쓰기 위해 | 완료 | | 2026-09-13 | 브랜치는 develop(기본, Squash 머지)에서 통합하고 main은 릴리즈 PR(Merge 머지)만 받는다 | 출시본과 개발 중인 코드를 분리한다. 릴리즈 PR을 squash하면 다음 릴리즈에 이미 나간 변경이 다시 뜨므로 main은 Merge만 쓴다 | 채택 | | 2026-09-13 | 공개할 수 없는 문서(운영안 · 제출 절차)는 비공개 저장소 `paxo-app/internal`에 두고 `docs/private/`에 clone한다 | 공개 저장소에 둘 수 없는 내용도 이력 관리가 필요하다 | 채택 | +| 2026-09-25 | 응답 대기 화면은 단계별 안내 문구 · 캡처 미리보기 · 해설 자리 스켈레톤으로 보여주고, 15초가 넘으면 취소를 연다 | 스피너 한 줄만으로는 멈춘 것처럼 보였고 진행 중인 요청을 끊을 방법이 없었다 | 제안 (9/29 회의) | +| 2026-09-25 | 해설 속도 개선은 두 단계로 나눈다. 1단계는 대기 UX와 API 기준선 측정, 2단계는 해설 스트리밍 | 스트리밍은 앱 · 프록시 계약을 함께 바꿔야 해서 프록시 선배포와 담당자 승인이 필요하다. 측정 근거를 먼저 남긴다 | 제안 (9/29 회의) | +| 2026-09-25 | 대기 중 취소는 정답 확정과 먼저 처리된 쪽을 따른다. 확정 전 취소는 무료 횟수를 차감하지 않고 늦은 응답은 버린다. 확정 후 취소는 해설에만 적용된다 | 정답을 못 받은 사용자에게 차감하지 않는다는 기존 규칙을 취소에도 그대로 적용한다 | 제안 (과금 담당 승인 필요) | From 950a147906d7530527615b413592d0ee9fddbd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Fri, 25 Sep 2026 15:09:10 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=C2=B7=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EB=90=9C=20=ED=95=B4=EC=84=A4=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=C2=B7=20=EB=8F=99=EC=9E=91=20=EC=A4=84=EC=9D=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=B8=A1=EC=A0=95=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 소유권을 캡처 전부터 잡아, 영역 선택 중 기록으로 바꾸면 이전 풀이가 끼어들지 않게 한다 - 실행 전에 취소된 해설 작업이 새 화면의 상태를 바꾸지 않게 진입 시 소유권을 확인한다 - 대기 도중 동작 줄이기를 켜면 스켈레톤 반복 애니메이션을 즉시 멈춘다 - 측정 스크립트에서 405는 예열 GET만 정상으로 보고 POST에서는 실패로 집계한다 --- Paxo/AppState.swift | 12 ++++++++++-- Paxo/UI/WaitingView.swift | 17 ++++++++++++----- scripts/measure-latency.sh | 4 +++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index 55f5f98..71372a5 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -267,14 +267,21 @@ final class AppState: ObservableObject { private func runSolve() async { toastDismissTask?.cancel() + // 영역을 고르는 동안 기록으로 화면을 바꿀 수 있으므로 소유권은 캡처 전부터 잡는다 + let requestID = requestGate.begin() let capture: ScreenCapturer.Capture do { - guard let captured = try await capturer.captureInteractive(mode: captureMode) else { + let captured = try await capturer.captureInteractive(mode: captureMode) + guard requestGate.isCurrent(requestID) else { return } + guard let captured else { + requestGate.finish(requestID) phase = .idle return } capture = captured } catch { + guard requestGate.isCurrent(requestID) else { return } + requestGate.finish(requestID) toast.dismiss() phase = .failedAnswer(errorMessage(from: error)) resultPanel.show(appState: self, on: lastCaptureScreen) @@ -285,7 +292,6 @@ final class AppState: ObservableObject { let result = SolveResult(preset: preset) cacheImage(capture.data, for: result.id) current = result - let requestID = requestGate.begin() waitingSince = Date() phase = .solvingAnswer presentSolving() @@ -355,6 +361,8 @@ final class AppState: ObservableObject { } private func runExplanation(requestID: UUID) async { + // 예약만 되고 실행 전에 취소된 작업이 새 화면을 건드리지 않게 한다 + guard requestGate.isCurrent(requestID), !Task.isCancelled else { return } guard let current, let image = imageCache[current.id], let answer = current.answer, diff --git a/Paxo/UI/WaitingView.swift b/Paxo/UI/WaitingView.swift index 11ee7c0..f3fd761 100644 --- a/Paxo/UI/WaitingView.swift +++ b/Paxo/UI/WaitingView.swift @@ -36,11 +36,18 @@ struct ExplanationWaitingView: View { .opacity(dimmed ? 0.45 : 1) } } - .onAppear { - guard !reduceMotion else { return } - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - dimmed = true - } + .onAppear { updatePulse() } + .onChange(of: reduceMotion) { updatePulse() } + } + + /// 대기 도중 "동작 줄이기"를 켜도 반복 애니메이션이 바로 멈추도록, 애니메이션 없이 값을 되돌린다 + private func updatePulse() { + var still = Transaction() + still.disablesAnimations = true + withTransaction(still) { dimmed = false } + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + dimmed = true } } diff --git a/scripts/measure-latency.sh b/scripts/measure-latency.sh index 3eacc53..57f3321 100755 --- a/scripts/measure-latency.sh +++ b/scripts/measure-latency.sh @@ -127,7 +127,9 @@ def sec(value, digits): print(f"{'구분':<22}{'성공/전체':>10}{'새 연결':>8}{'TLS까지 p50':>13}{'총 p50':>9}{'총 p90':>9}") for mode, req in [("cold", "answer"), ("warm", "answer"), ("cold", "explanation"), ("warm", "prewarm_get")]: group = [r for r in rows if r["mode"] == mode and r["request"] == req] - ok = [r for r in group if r["http_code"] in ("200", "405")] + # 예열 GET은 405가 정상이다. 정답 · 해설 POST에서 405는 실패다. + expected = ("405",) if req == "prewarm_get" else ("200",) + ok = [r for r in group if r["http_code"] in expected] total = [float(r["total_s"]) for r in ok] tls = [float(r["tls_s"]) for r in ok] conns = sum(int(r["new_connections"]) for r in group) From 57528cf9e97ececd6083f39e3bfab4d509321e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Tue, 29 Sep 2026 21:04:56 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=B7=A8=EC=86=8C=EB=A5=BC=203?= =?UTF-8?q?=EC=B4=88=EB=B6=80=ED=84=B0=20=EC=97=B4=EA=B3=A0=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=9B=90=ED=98=95=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=91=9C=EC=8B=9C=EC=99=80=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=84=A0=ED=83=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정답 · 해설 대기 모두 요청 시작 3초 후부터 취소할 수 있다 (영역 선택 중에는 기존 Esc) - 취소 자리를 처음부터 잡아 두어 버튼이 생겨도 화면이 튀지 않고, 3초 전에는 클릭 · 키보드 · VoiceOver로 실행되지 않는다 - 토스트 스피너를 기다린 시간으로 채우는 원형 진행 표시로 바꾼다 (실제 진행률을 몰라 90%에서 멈추는 추정치) - 설정에서 토스트 대기 방식을 고른다: 단계 안내(기본) / 간단히. 간단히도 15초부터 오래 걸린다고 알리고 3초 후 × 취소를 쓴다 - 대기 방식은 토스트를 띄울 때 고정하고, 새 캡처를 시작하면 이전 정답 토스트를 닫아 넓은 대기 화면이 잘리지 않게 한다 - 곧 끝난다고 약속하는 "거의 다 됐어요"를 "답을 정리하고 있어요"로 바꾼다 --- Paxo/AppState.swift | 7 ++ Paxo/Models.swift | 46 ++++++- Paxo/UI/SettingsView.swift | 14 +++ Paxo/UI/ToastController.swift | 6 +- Paxo/UI/WaitingView.swift | 174 +++++++++++++++++++------- PaxoTests/CodableRoundTripTests.swift | 12 ++ PaxoTests/WaitingProgressTests.swift | 36 ++++++ PaxoTests/WaitingStageTests.swift | 26 +++- 8 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 PaxoTests/WaitingProgressTests.swift diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index 71372a5..64dccff 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -49,6 +49,10 @@ final class AppState: ObservableObject { @Published var toastDuration: Double { didSet { UserDefaults.standard.set(toastDuration, forKey: "toastDuration") } } + /// 토스트가 기다리는 동안의 모습 (단계 안내 / 간단히) + @Published var toastWaitingStyle: ToastWaitingStyle { + didSet { UserDefaults.standard.set(toastWaitingStyle.rawValue, forKey: "toastWaitingStyle") } + } /// 전역 단축키 (설정에서 변경 가능) @Published var hotkey: HotkeySpec { didSet { @@ -153,6 +157,7 @@ final class AppState: ObservableObject { resultDisplayMode = ResultDisplayMode(rawValue: UserDefaults.standard.string(forKey: "resultDisplayMode") ?? "") ?? .panel toastDuration = UserDefaults.standard.object(forKey: "toastDuration") as? Double ?? 4.0 + toastWaitingStyle = ToastWaitingStyle.restored(from: UserDefaults.standard.string(forKey: "toastWaitingStyle")) if let data = UserDefaults.standard.data(forKey: "hotkeySpec"), let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data) { @@ -267,6 +272,8 @@ final class AppState: ObservableObject { private func runSolve() async { toastDismissTask?.cancel() + // 작은 정답 토스트가 남은 채 넓은 대기 화면으로 바뀌면 잘린다. 대기 토스트는 요청 시작 때 새 크기로 다시 뜬다. + toast.dismiss() // 영역을 고르는 동안 기록으로 화면을 바꿀 수 있으므로 소유권은 캡처 전부터 잡는다 let requestID = requestGate.begin() let capture: ScreenCapturer.Capture diff --git a/Paxo/Models.swift b/Paxo/Models.swift index ed4659b..74a15b1 100644 --- a/Paxo/Models.swift +++ b/Paxo/Models.swift @@ -121,9 +121,12 @@ enum WaitingStage: CaseIterable, Equatable { case slow case explaining - /// 이 시간을 넘기면 오래 걸린다고 알리고 취소 버튼을 보여준다 + /// 이 시간을 넘기면 오래 걸린다고 알린다 static let slowThreshold: TimeInterval = 15 + /// 취소를 여는 시점. 캡처 직후 실수로 눌러 멀쩡한 요청을 끊는 것만 막는다. + static let cancelThreshold: TimeInterval = 3 + /// 정답 대기. 시계가 뒤로 가서 음수가 나와도 첫 단계로 본다. static func answerStage(elapsed: TimeInterval) -> WaitingStage { switch elapsed { @@ -138,11 +141,16 @@ enum WaitingStage: CaseIterable, Equatable { elapsed < slowThreshold ? .explaining : .slow } + static func allowsCancel(elapsed: TimeInterval) -> Bool { + elapsed >= cancelThreshold + } + + /// 실제 진행률을 모르므로 "곧 끝난다"고 약속하는 문구는 쓰지 않는다 var message: String { switch self { case .reading: return "문제를 읽고 있어요" case .thinking: return "풀이를 떠올리는 중이에요" - case .finishing: return "거의 다 됐어요" + case .finishing: return "답을 정리하고 있어요" case .slow: return "조금 오래 걸리고 있어요" case .explaining: return "해설을 정리하고 있어요" } @@ -161,6 +169,38 @@ enum WaitingStage: CaseIterable, Equatable { case .explaining: return "book" } } +} + +/// 토스트가 기다리는 동안 보여줄 모습 +enum ToastWaitingStyle: String, CaseIterable, Codable, Identifiable { + case detailed + case simple + + var id: String { rawValue } + + var displayName: String { + switch self { + case .detailed: return "단계 안내 (기본)" + case .simple: return "간단히" + } + } - var allowsCancel: Bool { self == .slow } + /// 저장된 값이 없거나 알 수 없으면 개선된 안내를 기본으로 쓴다 + static func restored(from rawValue: String?) -> ToastWaitingStyle { + rawValue.flatMap(ToastWaitingStyle.init(rawValue:)) ?? .detailed + } +} + +/// 토스트의 원형 진행 표시에 쓰는 추정 진행률. +/// 서버는 답을 한 번에 보내서 실제 진행률을 알 수 없다. 기다린 시간으로 처음엔 빠르게, 점점 느리게 채우고 +/// 답이 오기 전에는 가득 차지 않도록 90%에서 멈춘다. +enum WaitingProgress { + static let ceiling = 0.9 + /// 곡선이 차오르는 속도. 측정 스크립트로 정답 응답 시간을 잰 뒤 조정한다. + static let timeConstant: TimeInterval = 5 + + static func estimatedFraction(elapsed: TimeInterval) -> Double { + guard elapsed > 0 else { return 0 } + return ceiling * (1 - exp(-elapsed / timeConstant)) + } } diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 48c91aa..7792ffc 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -35,6 +35,20 @@ struct SettingsView: View { .monospacedDigit() } Slider(value: $appState.toastDuration, in: 2...10, step: 1) + + Picker("기다리는 동안", selection: $appState.toastWaitingStyle) { + ForEach(ToastWaitingStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + Text( + (appState.toastWaitingStyle == .detailed + ? "지금 무엇을 하는지 문구로 알려줍니다." + : "'푸는 중…'만 보여줍니다.") + + " 진행 원은 기다린 시간으로 그린 표시예요. 실제 남은 시간과는 다를 수 있어요." + ) + .font(.caption) + .foregroundStyle(.secondary) } } diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index 983368a..b28ef4c 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -9,8 +9,9 @@ final class ToastController { func show(appState: AppState, on screen: NSScreen? = nil) { let host = ensurePanel(appState: appState) + // 대기 모습은 띄우는 순간에 고정한다. 대기 중 설정을 바꿔도 이미 잰 토스트 크기와 어긋나지 않게. host.rootView = AnyView( - ToastView().environmentObject(appState).fixedSize() + ToastView(waitingStyle: appState.toastWaitingStyle).environmentObject(appState).fixedSize() ) guard let panel, let screen = screen ?? NSScreen.main else { return } @@ -79,6 +80,7 @@ final class ToastController { private struct ToastView: View { @EnvironmentObject private var appState: AppState + let waitingStyle: ToastWaitingStyle var body: some View { content @@ -95,7 +97,7 @@ private struct ToastView: View { private var content: some View { switch appState.phase { case .capturing, .solvingAnswer: - ToastWaitingView() + ToastWaitingView(style: waitingStyle) default: if let answer = appState.current?.answer { Text(answer) diff --git a/Paxo/UI/WaitingView.swift b/Paxo/UI/WaitingView.swift index f3fd761..a5de760 100644 --- a/Paxo/UI/WaitingView.swift +++ b/Paxo/UI/WaitingView.swift @@ -11,8 +11,8 @@ struct AnswerWaitingView: View { if let thumbnail = appState.currentThumbnail { CaptureThumbnailCard(image: thumbnail, preset: appState.current?.preset ?? appState.preset) } - WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage in - WaitingStageRow(stage: stage) + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage, elapsed in + WaitingStageRow(stage: stage, showsCancel: elapsed.map(WaitingStage.allowsCancel) ?? false) } } } @@ -25,9 +25,9 @@ struct ExplanationWaitingView: View { @State private var dimmed = false var body: some View { - WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.explanationStage) { stage in + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.explanationStage) { stage, elapsed in VStack(alignment: .leading, spacing: 10) { - WaitingStageRow(stage: stage) + WaitingStageRow(stage: stage, showsCancel: elapsed.map(WaitingStage.allowsCancel) ?? false) VStack(alignment: .leading, spacing: 7) { skeletonLine(width: 290) skeletonLine(width: 240) @@ -58,81 +58,169 @@ struct ExplanationWaitingView: View { } } -/// 토스트용 한 줄 대기 표시. -/// 토스트는 뜰 때 한 번만 크기를 재므로, 가장 긴 단계(문구 + 취소 버튼)가 들어가는 고정 폭을 쓴다. +/// 토스트용 한 줄 대기 표시. 스피너 대신 기다린 시간으로 채우는 원을 쓴다. +/// 토스트는 뜰 때 한 번만 크기를 재므로, 가장 긴 문구와 취소 자리가 들어가는 고정 폭을 쓴다. struct ToastWaitingView: View { @EnvironmentObject private var appState: AppState + let style: ToastWaitingStyle var body: some View { - WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage in - WaitingStageRow(stage: stage, compact: true) + WaitingTimeline(since: appState.waitingSince, stage: WaitingStage.answerStage) { stage, elapsed in + let showsCancel = elapsed.map(WaitingStage.allowsCancel) ?? false + switch style { + case .detailed: + HStack(spacing: 10) { + ProgressRing(since: appState.waitingSince, symbol: stage.symbolName, description: stage.message) + Text(stage.message) + .font(.title3) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: false) + } + .frame(width: 320, alignment: .leading) + case .simple: + // 문구가 없으면 오래 걸릴 때 원만 멈춰 보이므로, 이때만 이유를 한 줄 알려준다 + let message = stage == .slow ? stage.message : "푸는 중…" + HStack(spacing: 10) { + ProgressRing(since: appState.waitingSince, symbol: nil, description: message) + Text(message) + .font(.title3) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: true) + } + .frame(width: 250, alignment: .leading) + } } - .frame(width: 320, alignment: .leading) } } -/// 단계 아이콘 · 문구 · (오래 걸리면) 취소 버튼 +/// 단계 아이콘 · 문구 · 취소 버튼. 취소 자리는 처음부터 잡아 두어 버튼이 생겨도 화면이 튀지 않는다. struct WaitingStageRow: View { - @EnvironmentObject private var appState: AppState @Environment(\.accessibilityReduceMotion) private var reduceMotion let stage: WaitingStage - var compact = false + let showsCancel: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: stage.symbolName) + .font(.title2) + .foregroundStyle(.tint) + .symbolEffect(.pulse, isActive: !reduceMotion) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 28) + Text(stage.message) + .font(.callout.weight(.medium)) + Spacer(minLength: 8) + CancelSlot(visible: showsCancel, compact: false) + } + if let detail = stage.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: stage) + } +} + +/// 3초 전에는 자리만 차지하고 클릭 · 키보드 · VoiceOver 어느 쪽으로도 실행되지 않는 취소 버튼 +private struct CancelSlot: View { + @EnvironmentObject private var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let visible: Bool + let compact: Bool var body: some View { Group { if compact { - HStack(spacing: 10) { - icon.font(.title3) - Text(stage.message) - .font(.title3) + Button { + appState.cancelSolve() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) .foregroundStyle(.secondary) - Spacer(minLength: 8) - if stage.allowsCancel { cancelButton } + .frame(width: 22, height: 22) + .background(Circle().fill(Color.secondary.opacity(0.15))) + .contentShape(Circle()) } + .buttonStyle(.plain) + .help("풀이 취소") + .accessibilityLabel("풀이 취소") } else { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - icon.font(.title2) - Text(stage.message) - .font(.callout.weight(.medium)) - } - if let detail = stage.detail { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - } - if stage.allowsCancel { cancelButton } + Button("취소") { + appState.cancelSolve() } + .controlSize(.small) } } - .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: stage) + .opacity(visible ? 1 : 0) + .disabled(!visible) + .allowsHitTesting(visible) + .accessibilityHidden(!visible) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: visible) } +} - private var icon: some View { - Image(systemName: stage.symbolName) - .foregroundStyle(.tint) - .symbolEffect(.pulse, isActive: !reduceMotion) - .contentTransition(.symbolEffect(.replace)) - .frame(width: 28) +/// 기다린 시간으로 채우는 원. 실제 진행률이 아니라 추정치라 90%를 넘지 않는다. +/// 원호만 자주(30Hz) 다시 그리고, 가운데 아이콘은 밖에 두어 펄스 효과가 매 프레임 다시 시작되지 않게 한다. +private struct ProgressRing: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let since: Date? + let symbol: String? + let description: String + + var body: some View { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.25), lineWidth: 3) + if reduceMotion { + TimelineView(.periodic(from: since ?? .now, by: 1)) { context in + arc(at: context.date) + } + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30)) { context in + arc(at: context.date) + } + } + if let symbol { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tint) + .symbolEffect(.pulse, isActive: !reduceMotion) + .contentTransition(.symbolEffect(.replace)) + } + } + .frame(width: 26, height: 26) + .accessibilityElement(children: .ignore) + .accessibilityLabel("진행 중") + .accessibilityValue(description) } - private var cancelButton: some View { - Button("취소") { - appState.cancelSolve() + @ViewBuilder + private func arc(at date: Date) -> some View { + let fraction = since.map { WaitingProgress.estimatedFraction(elapsed: date.timeIntervalSince($0)) } ?? 0 + if fraction > 0 { + Circle() + .trim(from: 0, to: fraction) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) } - .controlSize(.small) } } /// 1초마다 경과 시간을 다시 계산해 단계를 넘긴다. 틱 수가 아니라 시작 시각 기준이라 갱신이 밀려도 어긋나지 않는다. +/// 요청이 시작되기 전(영역 선택 중)에는 경과 시간을 넘기지 않아 취소가 열리지 않는다. private struct WaitingTimeline: View { let since: Date? let stage: (TimeInterval) -> WaitingStage - @ViewBuilder let content: (WaitingStage) -> Content + @ViewBuilder let content: (WaitingStage, TimeInterval?) -> Content var body: some View { TimelineView(.periodic(from: since ?? .now, by: 1)) { context in - content(stage(context.date.timeIntervalSince(since ?? context.date))) + let elapsed = since.map { context.date.timeIntervalSince($0) } + content(stage(elapsed ?? 0), elapsed) } } } diff --git a/PaxoTests/CodableRoundTripTests.swift b/PaxoTests/CodableRoundTripTests.swift index becb281..474c857 100644 --- a/PaxoTests/CodableRoundTripTests.swift +++ b/PaxoTests/CodableRoundTripTests.swift @@ -53,5 +53,17 @@ struct CodableRoundTripTests { #expect(CaptureMode.region.rawValue == "region") #expect(ResultDisplayMode.panel.rawValue == "panel") #expect(PanelPosition.topRight.rawValue == "topRight") + #expect(ToastWaitingStyle.detailed.rawValue == "detailed") + #expect(ToastWaitingStyle.simple.rawValue == "simple") + } + + /// 설정이 없던 기존 사용자와 알 수 없는 값은 개선된 안내를 받는다. + @Test(arguments: [nil, "", "unknown"] as [String?]) + func 토스트_대기_방식은_모르면_단계_안내로_돌아간다(rawValue: String?) { + #expect(ToastWaitingStyle.restored(from: rawValue) == .detailed) + } + + @Test func 저장된_토스트_대기_방식을_그대로_읽는다() { + #expect(ToastWaitingStyle.restored(from: "simple") == .simple) } } diff --git a/PaxoTests/WaitingProgressTests.swift b/PaxoTests/WaitingProgressTests.swift new file mode 100644 index 0000000..eaea9e2 --- /dev/null +++ b/PaxoTests/WaitingProgressTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing + +@testable import Paxo + +/// 토스트의 원은 실제 진행률이 아니라 추정치다. 답이 오기 전에 가득 차거나 뒤로 가면 +/// "다 됐는데 왜 안 나오지"라는 오해를 만든다. +struct WaitingProgressTests { + @Test(arguments: [0.0, -1, -100]) + func 시작_전에는_비어_있다(elapsed: TimeInterval) { + #expect(WaitingProgress.estimatedFraction(elapsed: elapsed) == 0) + } + + /// 아주 긴 시간에서는 Double 반올림으로 정확히 상한과 같아질 수 있어 이하로 본다. + @Test(arguments: [0.1, 1, 3, 8, 15, 60, 600, 1_000_000]) + func 상한을_넘지_않는다(elapsed: TimeInterval) { + let fraction = WaitingProgress.estimatedFraction(elapsed: elapsed) + #expect(fraction >= 0) + #expect(fraction <= WaitingProgress.ceiling) + } + + @Test func 시간이_지날수록_줄지_않는다() { + let samples = stride(from: 0.0, through: 120, by: 0.5).map { + WaitingProgress.estimatedFraction(elapsed: $0) + } + for (earlier, later) in zip(samples, samples.dropFirst()) { + #expect(later >= earlier) + } + } + + /// 처음엔 빠르게, 점점 느리게 찬다. 곡선을 바꾸면 이 값도 함께 바꾼다. + @Test(arguments: [(3.0, 0.41), (8, 0.72), (15, 0.86)]) + func 주요_시점의_진행률(elapsed: TimeInterval, expected: Double) { + #expect(abs(WaitingProgress.estimatedFraction(elapsed: elapsed) - expected) < 0.01) + } +} diff --git a/PaxoTests/WaitingStageTests.swift b/PaxoTests/WaitingStageTests.swift index 0031484..db9e0af 100644 --- a/PaxoTests/WaitingStageTests.swift +++ b/PaxoTests/WaitingStageTests.swift @@ -36,9 +36,29 @@ struct WaitingStageTests { #expect(!stage.symbolName.isEmpty) } - /// 취소는 오래 걸릴 때만 연다. 처음부터 보이면 누르기 쉬워 멀쩡한 요청이 끊긴다. + /// 취소는 3초부터 연다. 캡처 직후 바로 보이면 실수로 눌러 멀쩡한 요청이 끊긴다. + /// 정답 대기와 해설 대기가 같은 기준을 쓴다. + @Test(arguments: [ + (-1.0, false), + (0, false), + (2.9, false), + (3, true), + (15, true), + (60, true), + ]) + func 취소는_3초부터_열린다(elapsed: TimeInterval, expected: Bool) { + #expect(WaitingStage.allowsCancel(elapsed: elapsed) == expected) + } + + /// 실제 진행률을 모르므로 곧 끝난다고 약속하는 문구를 쓰지 않는다. + @Test(arguments: WaitingStage.allCases) + func 완료를_약속하는_문구가_없다(stage: WaitingStage) { + #expect(!stage.message.contains("다 됐")) + } + + /// 오래 걸린다는 설명은 취소와 별개로 15초부터만 보여준다. @Test(arguments: WaitingStage.allCases) - func 취소는_느림_단계에서만_보인다(stage: WaitingStage) { - #expect(stage.allowsCancel == (stage == .slow)) + func 설명은_느림_단계에서만_있다(stage: WaitingStage) { + #expect((stage.detail != nil) == (stage == .slow)) } } From 2967ba33444ef155bd9ed75fd93cb3125a4a20db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Tue, 29 Sep 2026 21:05:23 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=EC=B7=A8=EC=86=8C=203=EC=B4=88=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EA=B3=BC=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=9D=84=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/architecture.md | 2 +- docs/decisions.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba1c754..9e11649 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 2. **영역 선택 (Selection)**: 문제 영역을 드래그하여 지정 (`Esc` 취소). 설정에서 **전체 화면** 모드 활성화 시 드래그 없이 즉시 캡처. 3. **결과 노출 (Result)**: 정답이 먼저 화면에 표시되고, 이어서 해설이 스트리밍된다. 4. **빠른 채점 모드 (Fast Grading)**: 설정에서 켤 수 있으며, 정답만 크게 강조 표시하고 해설은 "해설 보기" 클릭 시 Lazy-load 방식으로 생성한다 (문제집 셀프 채점용). -5. **토스트 모드 (Toast Mode)**: 정답만 토스트 팝업으로 2~10초간 노출된 후 사라지며 해설은 아예 생성하지 않는다. +5. **토스트 모드 (Toast Mode)**: 정답만 토스트 팝업으로 2~10초간 노출된 후 사라지며 해설은 아예 생성하지 않는다. 기다리는 동안의 모습은 설정에서 **단계 안내(기본)** 또는 **간단히**로 고를 수 있다. 6. **결과 패널 위치**: 화면 내 7가지 프리셋 중 원하는 위치를 지정할 수 있다. ## 2. 프로젝트 구조 diff --git a/docs/architecture.md b/docs/architecture.md index f1ab863..8dae31a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ - 정답과 해설은 각각 별도의 API 요청으로 처리한다. - 무료 횟수는 '정답 요청'이 성공했을 때만 차감한다. 해설 요청 실패 시에는 추가로 차감하지 않는다. - 토스트 모드에서는 해설을 요청하지 않는다. -- 기다리는 동안 15초가 지나면 취소할 수 있다. 정답 확정 전에 취소하면 무료 횟수를 차감하지 않으며, 취소나 기록 전환 뒤에 도착한 응답은 `SolveRequestGate`가 버린다. +- 정답 · 해설 요청 3초 후부터 취소할 수 있다. 정답 확정 전에 취소하면 무료 횟수를 차감하지 않으며, 취소나 기록 전환 뒤에 도착한 응답은 `SolveRequestGate`가 버린다. ## 앱 ↔ 프록시 API 스펙 (Contract) diff --git a/docs/decisions.md b/docs/decisions.md index f2e059d..7b11b18 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,6 +12,7 @@ | 2026-09-13 | 저장소를 공개로 전환했다 | GitHub Actions 무료 · 보호 규칙 · 시크릿 스캔을 쓰기 위해 | 완료 | | 2026-09-13 | 브랜치는 develop(기본, Squash 머지)에서 통합하고 main은 릴리즈 PR(Merge 머지)만 받는다 | 출시본과 개발 중인 코드를 분리한다. 릴리즈 PR을 squash하면 다음 릴리즈에 이미 나간 변경이 다시 뜨므로 main은 Merge만 쓴다 | 채택 | | 2026-09-13 | 공개할 수 없는 문서(운영안 · 제출 절차)는 비공개 저장소 `paxo-app/internal`에 두고 `docs/private/`에 clone한다 | 공개 저장소에 둘 수 없는 내용도 이력 관리가 필요하다 | 채택 | -| 2026-09-25 | 응답 대기 화면은 단계별 안내 문구 · 캡처 미리보기 · 해설 자리 스켈레톤으로 보여주고, 15초가 넘으면 취소를 연다 | 스피너 한 줄만으로는 멈춘 것처럼 보였고 진행 중인 요청을 끊을 방법이 없었다 | 제안 (9/29 회의) | +| 2026-09-25 | 응답 대기 화면은 단계별 안내 문구 · 캡처 미리보기 · 해설 자리 스켈레톤으로 보여주고, 요청 3초 후부터 취소를 열며 15초부터 오래 걸린다고 알린다 | 스피너 한 줄만으로는 멈춘 것처럼 보였고 진행 중인 요청을 끊을 방법이 없었다 | 제안 (9/29 회의) | | 2026-09-25 | 해설 속도 개선은 두 단계로 나눈다. 1단계는 대기 UX와 API 기준선 측정, 2단계는 해설 스트리밍 | 스트리밍은 앱 · 프록시 계약을 함께 바꿔야 해서 프록시 선배포와 담당자 승인이 필요하다. 측정 근거를 먼저 남긴다 | 제안 (9/29 회의) | | 2026-09-25 | 대기 중 취소는 정답 확정과 먼저 처리된 쪽을 따른다. 확정 전 취소는 무료 횟수를 차감하지 않고 늦은 응답은 버린다. 확정 후 취소는 해설에만 적용된다 | 정답을 못 받은 사용자에게 차감하지 않는다는 기존 규칙을 취소에도 그대로 적용한다 | 제안 (과금 담당 승인 필요) | +| 2026-09-29 | 토스트 대기는 기다린 시간으로 채우는 원형 진행 표시(추정치, 최대 90%)를 쓰고, 단계 안내(기본)와 간단히 중에서 고를 수 있다 | 스피너보다 기다리는 느낌이 덜하고 토스트 크기를 늘리지 않는다. 서버가 답을 한 번에 보내 실제 진행률은 알 수 없으므로 가득 채우지 않는다 | 제안 | From 546ac35dab36c32d31aff4dec5fa9b097a221742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=EA=B2=B8?= Date: Wed, 30 Sep 2026 00:11:11 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=A0=95=EB=8B=B5=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=AB=ED=9E=88=EB=8A=94=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=8A=94=20=ED=8E=98=EC=9D=B4=EB=93=9C=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=94=EB=A1=9C=20=EB=8B=AB=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 취소 · 오류 · 새 캡처 · 기록 열기로 토스트를 닫을 때 페이드하면, 그 0.3초 사이 내용이 빈 자리("—")로 바뀌고 창도 그 크기로 줄어 작은 말풍선이 번쩍였다 - 정답을 보여준 뒤 자동으로 사라질 때만 페이드를 유지한다 --- Paxo/AppState.swift | 12 ++++++------ Paxo/UI/ToastController.swift | 8 +++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index 64dccff..1ed1b4a 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -236,7 +236,7 @@ final class AppState: ObservableObject { // 진행 중인 요청은 먼저 끊는다. 두면 늦은 응답이 방금 연 기록을 덮어쓴다. cancelSolve() toastDismissTask?.cancel() - toast.dismiss() + toast.dismiss(animated: false) current = item phase = item.explanation == nil ? .answerReady : .done resultPanel.show(appState: self, on: Self.screenUnderMouse()) @@ -262,7 +262,7 @@ final class AppState: ObservableObject { case .solvingAnswer: phase = .idle resultPanel.hide() - toast.dismiss() + toast.dismiss(animated: false) case .solvingExplanation: phase = .answerReady default: @@ -273,7 +273,7 @@ final class AppState: ObservableObject { private func runSolve() async { toastDismissTask?.cancel() // 작은 정답 토스트가 남은 채 넓은 대기 화면으로 바뀌면 잘린다. 대기 토스트는 요청 시작 때 새 크기로 다시 뜬다. - toast.dismiss() + toast.dismiss(animated: false) // 영역을 고르는 동안 기록으로 화면을 바꿀 수 있으므로 소유권은 캡처 전부터 잡는다 let requestID = requestGate.begin() let capture: ScreenCapturer.Capture @@ -289,7 +289,7 @@ final class AppState: ObservableObject { } catch { guard requestGate.isCurrent(requestID) else { return } requestGate.finish(requestID) - toast.dismiss() + toast.dismiss(animated: false) phase = .failedAnswer(errorMessage(from: error)) resultPanel.show(appState: self, on: lastCaptureScreen) return @@ -332,7 +332,7 @@ final class AppState: ObservableObject { guard requestGate.isCurrent(requestID) else { return } requestGate.finish(requestID) waitingSince = nil - toast.dismiss() + toast.dismiss(animated: false) if error is CancellationError { phase = .idle resultPanel.hide() @@ -346,7 +346,7 @@ final class AppState: ObservableObject { private func presentSolving() { switch resultDisplayMode { case .panel: - toast.dismiss() + toast.dismiss(animated: false) resultPanel.show(appState: self, on: lastCaptureScreen) case .toast: resultPanel.hide() diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index b28ef4c..5d38abe 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -38,9 +38,15 @@ final class ToastController { } } - func dismiss() { + /// 정답을 보여준 뒤에만 페이드로 닫는다. 취소 · 오류처럼 정답 없이 닫을 때 페이드하면 + /// 그 사이 내용이 빈 자리("—")로 바뀌고 창도 그 크기로 줄어 작은 말풍선이 번쩍인다. + func dismiss(animated: Bool = true) { guard let panel, isVisible else { return } isVisible = false + guard animated else { + panel.orderOut(nil) + return + } NSAnimationContext.runAnimationGroup( { ctx in ctx.duration = 0.3