diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c65e0ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +# 빌드 산출·환경별 값 +2_frontend/tsconfig.tsbuildinfo +2_frontend/.env.production diff --git a/2_frontend/src/App.tsx b/2_frontend/src/App.tsx index 4ce86d8..83e409d 100644 --- a/2_frontend/src/App.tsx +++ b/2_frontend/src/App.tsx @@ -18,7 +18,7 @@ export default function App() { return () => setBridgeNavigate(null) }, [navigate]) - // route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용). + // route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Alt+W 복귀용). useEffect(() => { reportRoute(location.pathname) }, [location.pathname]) diff --git a/2_frontend/src/features/snap/components/Composer.test.tsx b/2_frontend/src/features/snap/components/Composer.test.tsx index 4111cf8..b8fe369 100644 --- a/2_frontend/src/features/snap/components/Composer.test.tsx +++ b/2_frontend/src/features/snap/components/Composer.test.tsx @@ -30,35 +30,50 @@ describe("Composer 캡쳐 이미지 첨부", () => { const wv = mockWebview() initBridgeNavigate() - const { unmount } = render( {}} acceptCapture />) - act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" })) + const { unmount } = render( {}} capturePath="/snap/new" />) + act(() => + wv.emit({ + type: "capture.image", + path: "/snap/new", + dataUrl: "data:image/png;base64,abc", + }) + ) expect(screen.getAllByRole("img")).toHaveLength(1) unmount() - render( {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션 + render( {}} capturePath="/snap/new" />) expect(screen.queryAllByRole("img")).toHaveLength(0) }) - // 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로 - // capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음. - it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => { + it("다른 대화용 캡쳐는 소비하지 않음", () => { const wv = mockWebview() initBridgeNavigate() - render( {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션 - act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" })) + render( {}} capturePath="/snap/s/current" />) + act(() => + wv.emit({ + type: "capture.image", + path: "/snap/s/previous", + dataUrl: "data:image/png;base64,xyz", + }) + ) - expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음 - // pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함 - expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz") + expect(screen.queryAllByRole("img")).toHaveLength(0) + expect(consumePendingCaptureImage("/snap/s/previous")).toBe("data:image/png;base64,xyz") }) it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => { const wv = mockWebview() initBridgeNavigate() const onSend = vi.fn() - render() - act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" })) + render() + act(() => + wv.emit({ + type: "capture.image", + path: "/snap/new", + dataUrl: "data:image/png;base64,eA==", + }) + ) fireEvent.click(screen.getByRole("button", { name: "전송" })) @@ -71,10 +86,16 @@ describe("Composer 캡쳐 이미지 첨부", () => { it("이미지는 최대 4장까지만 첨부한다", () => { const wv = mockWebview() initBridgeNavigate() - render( {}} acceptCapture />) + render( {}} capturePath="/snap/new" />) for (let i = 0; i < 5; i++) { - act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` })) + act(() => + wv.emit({ + type: "capture.image", + path: "/snap/new", + dataUrl: `data:image/png;base64,eA${i}=`, + }) + ) } expect(screen.queryAllByRole("img")).toHaveLength(4) diff --git a/2_frontend/src/features/snap/components/Composer.tsx b/2_frontend/src/features/snap/components/Composer.tsx index 973f04b..8f7f394 100644 --- a/2_frontend/src/features/snap/components/Composer.tsx +++ b/2_frontend/src/features/snap/components/Composer.tsx @@ -12,10 +12,8 @@ interface Props { busy?: boolean onStop?: () => void placeholder?: string - // 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져 - // 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가 - // 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅. - acceptCapture?: boolean + // 이 Composer가 받을 캡처 대상 route. 대상이 다르면 pending을 남겨 목적지 리마운트가 소비함. + capturePath?: string } // Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀. @@ -47,7 +45,7 @@ function imageFromDataUrl(data: string): SnapImageInput | null { return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data } } -export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) { +export function Composer({ onSend, busy, onStop, placeholder, capturePath }: Props) { const [value, setValue] = useState("") // 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄. const [attachments, setAttachments] = useState([]) @@ -88,22 +86,18 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P return () => cancelAnimationFrame(raf) }, []) - // 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008). - // acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화 - // 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅). + // 캡처 이미지 수신 — 목적지 route만 pending을 소비해 네비게이션 리마운트 race를 막음. useEffect(() => { - if (!acceptCapture) return - const pending = consumePendingCaptureImage() + if (!capturePath) return + const pending = consumePendingCaptureImage(capturePath) if (pending) addImage(pending) const onCapture = () => { - // detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야 - // 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음. - const dataUrl = consumePendingCaptureImage() + const dataUrl = consumePendingCaptureImage(capturePath) if (dataUrl) addImage(dataUrl) } window.addEventListener("bridge:captureImage", onCapture) return () => window.removeEventListener("bridge:captureImage", onCapture) - }, [acceptCapture]) + }, [capturePath]) const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i)) const removeImageAttachment = (i: number) => diff --git a/2_frontend/src/features/snap/components/NavRail.tsx b/2_frontend/src/features/snap/components/NavRail.tsx index f49438c..9d52f5b 100644 --- a/2_frontend/src/features/snap/components/NavRail.tsx +++ b/2_frontend/src/features/snap/components/NavRail.tsx @@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
- + Source Nav @@ -79,7 +79,7 @@ export function NavRail({ blocks, containerRef }: Props) { className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors" > {copiedIdx === b.index ? ( - + ) : ( )} diff --git a/2_frontend/src/features/snap/pages/NewChatPage.tsx b/2_frontend/src/features/snap/pages/NewChatPage.tsx index ff51b4c..a8ded70 100644 --- a/2_frontend/src/features/snap/pages/NewChatPage.tsx +++ b/2_frontend/src/features/snap/pages/NewChatPage.tsx @@ -46,7 +46,7 @@ export default function NewChatPage() { onSend={start} busy={pending} placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…" - acceptCapture + capturePath={PATHS.SNAP_NEW} />
) diff --git a/2_frontend/src/features/snap/pages/SessionChatPage.tsx b/2_frontend/src/features/snap/pages/SessionChatPage.tsx index 9409616..6994d3c 100644 --- a/2_frontend/src/features/snap/pages/SessionChatPage.tsx +++ b/2_frontend/src/features/snap/pages/SessionChatPage.tsx @@ -223,6 +223,7 @@ export default function SessionChatPage() { busy={busy || generatingRemotely} onStop={stop} placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…" + capturePath={`/snap/s/${id}`} /> diff --git a/2_frontend/src/features/snippets/pages/SnippetPalettePage.test.tsx b/2_frontend/src/features/snippets/pages/SnippetPalettePage.test.tsx index 352ed83..2746826 100644 --- a/2_frontend/src/features/snippets/pages/SnippetPalettePage.test.tsx +++ b/2_frontend/src/features/snippets/pages/SnippetPalettePage.test.tsx @@ -1,3 +1,4 @@ +import { MemoryRouter } from "react-router-dom" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import userEvent from "@testing-library/user-event" import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" @@ -67,7 +68,11 @@ afterEach(() => { describe("새 스니펫 단축키", () => { it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => { - render() + render( + + + + ) fireEvent.keyDown(window, { key: "2", ctrlKey: true }) expect(screen.queryByRole("dialog")).not.toBeInTheDocument() @@ -79,7 +84,11 @@ describe("새 스니펫 단축키", () => { it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => { vi.useFakeTimers() createSnippet.mockImplementation((_input, options) => options.onSuccess()) - render() + render( + + + + ) const search = screen.getByRole("textbox") fireEvent.change(search, { target: { value: "기존 검색" } }) fireEvent.keyDown(window, { key: "n", ctrlKey: true }) @@ -104,7 +113,11 @@ describe("스니펫 Enter 복사", () => { }) ) vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText) - render() + render( + + + + ) fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } }) fireEvent.click(screen.getByText("B")) fireEvent.keyDown(window, { key: "Enter" }) @@ -122,7 +135,11 @@ describe("스니펫 Enter 복사", () => { it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => { const writeText = vi.fn().mockRejectedValue(new Error("denied")) vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText) - render() + render( + + + + ) fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } }) fireEvent.keyDown(window, { key: "ArrowDown" }) fireEvent.keyDown(window, { key: "Enter", isComposing: true }) @@ -136,7 +153,11 @@ describe("스니펫 Enter 복사", () => { it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => { const user = userEvent.setup() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() - render() + render( + + + + ) fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } }) await user.click(screen.getByRole("button", { name: "B" })) const copy = screen.getByRole("button", { name: "복사" }) @@ -154,7 +175,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => { it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => { const user = userEvent.setup() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() - render() + render( + + + + ) await user.type(screen.getByRole("textbox"), "B") await user.click(screen.getByRole("button", { name: "B" })) fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true }) @@ -169,7 +194,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => { it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => { const user = userEvent.setup() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() - render() + render( + + + + ) await user.type(screen.getByRole("textbox"), "B") await user.click(screen.getByRole("button", { name: "B" })) screen.getByRole("button", { name: "복사" }).focus() @@ -188,7 +217,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => { it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => { const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() - render() + render( + + + + ) const input = screen.getByRole("textbox") fireEvent.change(input, { target: { value: "B" } }) fireEvent.keyDown(input, { key: "Enter", ctrlKey: true }) @@ -208,7 +241,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => { it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => { vi.mocked(isWebView).mockReturnValue(false) const writeText = vi.spyOn(navigator.clipboard, "writeText") - render() + render( + + + + ) const input = screen.getByRole("textbox") fireEvent.change(input, { target: { value: "B" } }) fireEvent.keyDown(input, { key: "ArrowDown" }) @@ -220,7 +257,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => { describe("검색 후 선택할 때만 미리보기", () => { it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => { - render() + render( + + + + ) const input = screen.getByRole("textbox") expect(screen.queryByText("A")).not.toBeInTheDocument() expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument() @@ -244,7 +285,11 @@ describe("검색 후 선택할 때만 미리보기", () => { it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => { const writeText = vi.spyOn(navigator.clipboard, "writeText") - render() + render( + + + + ) fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } }) fireEvent.keyDown(window, { key: "Enter" }) expect(writeText).not.toHaveBeenCalled() @@ -259,7 +304,11 @@ describe("검색 후 선택할 때만 미리보기", () => { it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => { const user = userEvent.setup() - render() + render( + + + + ) const input = screen.getByRole("textbox") await waitFor(() => expect(input).toHaveFocus()) await user.type(input, "A") diff --git a/2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx b/2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx index 2e357e0..6c11f54 100644 --- a/2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx +++ b/2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx @@ -2,7 +2,10 @@ import { hostKind, send } from "@/lib/bridge/transport" import { LogoMark } from "@/shared/components/LogoMark" // 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침. import { useEffect, useMemo, useRef, useState } from "react" -import { Check, Search } from "lucide-react" +import { useNavigate } from "react-router-dom" +import { Check, MessageSquare, Search } from "lucide-react" +import { PATHS } from "@/config/routes" +import { Kbd } from "@/shared/components/Kbd" import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge" import { toast } from "sonner" import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey" @@ -25,6 +28,7 @@ import type { Snippet, SnippetInput } from "../types" const ALL_CATEGORY = "전체" export default function SnippetPalettePage() { + const navigate = useNavigate() const [q, setQ] = useState("") const { data: snippets = [], isLoading, error } = useSnippets() const recordUse = useRecordUse() @@ -307,6 +311,14 @@ export default function SnippetPalettePage() { placeholder="스니펫 검색…" className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none" /> + {/* 챗으로 전환 — 같은 창에서 route 만 바뀜(Ctrl+Alt+W 와 동일) */} + {/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */} diff --git a/2_frontend/src/lib/bridge/bridgeNavigate.test.ts b/2_frontend/src/lib/bridge/bridgeNavigate.test.ts index ab48496..372d74c 100644 --- a/2_frontend/src/lib/bridge/bridgeNavigate.test.ts +++ b/2_frontend/src/lib/bridge/bridgeNavigate.test.ts @@ -29,15 +29,20 @@ describe("bridgeNavigate", () => { await emit("bridge", { type: "navigate", path: "/snippet" }) await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" }) - await emit("bridge", { type: "capture.image", dataUrl: "data:image/png;base64,abc" }) + await emit("bridge", { + type: "capture.image", + path: "/snap/new", + dataUrl: "data:image/png;base64,abc", + }) expect(navigate).toHaveBeenCalledWith("/snippet") expect(onNavigate).toHaveBeenCalledTimes(1) expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" }) expect(onPasteTarget).toHaveBeenCalledTimes(1) expect(onCapture).toHaveBeenCalledTimes(1) - expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc") - expect(consumePendingCaptureImage()).toBe("") + expect(consumePendingCaptureImage("/snap/s/old")).toBe("") + expect(consumePendingCaptureImage("/snap/new")).toBe("data:image/png;base64,abc") + expect(consumePendingCaptureImage("/snap/new")).toBe("") window.removeEventListener("bridge:navigate", onNavigate) window.removeEventListener("bridge:pasteTarget", onPasteTarget) diff --git a/2_frontend/src/lib/bridge/bridgeNavigate.ts b/2_frontend/src/lib/bridge/bridgeNavigate.ts index eb3e20b..f990d9f 100644 --- a/2_frontend/src/lib/bridge/bridgeNavigate.ts +++ b/2_frontend/src/lib/bridge/bridgeNavigate.ts @@ -9,7 +9,7 @@ export interface PasteTarget { let navigateFn: NavigateFn | null = null let lastPasteTarget: PasteTarget = { name: "", app: "" } -let pendingCaptureImage = "" +let pendingCaptureImage: { path: string; dataUrl: string } | null = null /** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */ export function setBridgeNavigate(fn: NavigateFn | null) { @@ -21,10 +21,11 @@ export function getLastPasteTarget(): PasteTarget { return lastPasteTarget } -/** 마지막 캡처 이미지를 한 번만 소비함. */ -export function consumePendingCaptureImage(): string { - const dataUrl = pendingCaptureImage - pendingCaptureImage = "" +/** 지정한 대화에 도착한 마지막 캡처 이미지를 한 번만 소비함. */ +export function consumePendingCaptureImage(path: string): string { + if (pendingCaptureImage?.path !== path) return "" + const { dataUrl } = pendingCaptureImage + pendingCaptureImage = null return dataUrl } @@ -41,11 +42,9 @@ function handleBridgeMessage(data: { return } - if (data.type === "capture.image") { - pendingCaptureImage = data.dataUrl ?? "" - window.dispatchEvent( - new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } }) - ) + if (data.type === "capture.image" && data.path) { + pendingCaptureImage = { path: data.path, dataUrl: data.dataUrl ?? "" } + window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: pendingCaptureImage })) return } diff --git a/2_frontend/src/routes.tsx b/2_frontend/src/routes.tsx index 56efea0..b5ac5d1 100644 --- a/2_frontend/src/routes.tsx +++ b/2_frontend/src/routes.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from "react" +import { CenterSpinner } from "@/shared/components/CenterSpinner" import { Navigate, type RouteObject } from "react-router-dom" import Layout from "@/shared/components/Layout" import ProtectedRoute from "@/shared/components/ProtectedRoute" @@ -12,7 +13,7 @@ const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage")) const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage")) const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage")) -const fallback =
로딩...
+const fallback = const wrap = (el: React.ReactNode) => {el} diff --git a/2_frontend/src/shared/components/CenterSpinner.tsx b/2_frontend/src/shared/components/CenterSpinner.tsx new file mode 100644 index 0000000..11efc23 --- /dev/null +++ b/2_frontend/src/shared/components/CenterSpinner.tsx @@ -0,0 +1,10 @@ +import { Loader2 } from "lucide-react" + +/** 화면 정가운데 도는 스피너 — 페이지 lazy 로딩·로그인 확인 같은 전체 화면 대기용. */ +export function CenterSpinner({ label = "로딩 중" }: { label?: string }) { + return ( +
+ +
+ ) +} diff --git a/2_frontend/src/shared/components/ProtectedRoute.tsx b/2_frontend/src/shared/components/ProtectedRoute.tsx index 4fdc20d..1c30cb7 100644 --- a/2_frontend/src/shared/components/ProtectedRoute.tsx +++ b/2_frontend/src/shared/components/ProtectedRoute.tsx @@ -3,6 +3,7 @@ import { Navigate, Outlet, useLocation } from "react-router-dom" import { useMe } from "@/features/auth/hooks/useMe" import { useAuthStore } from "@/features/auth/store/authStore" import { PATHS } from "@/config/routes" +import { CenterSpinner } from "@/shared/components/CenterSpinner" /** * 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다. @@ -25,7 +26,7 @@ export default function ProtectedRoute() { return } if (me.isLoading) { - return
로그인 확인 중...
+ return } return } diff --git a/2_frontend/src/styles/globals.css b/2_frontend/src/styles/globals.css index 4b0f852..d4d5df1 100644 --- a/2_frontend/src/styles/globals.css +++ b/2_frontend/src/styles/globals.css @@ -146,6 +146,15 @@ "rlig" 1, "calt" 1; } + /* Tailwind v4 가 버튼 기본 cursor 를 default 로 바꿔서 — 클릭되는 건 전부 손가락 모양 */ + button:not(:disabled), + [role="button"]:not([aria-disabled="true"]), + a[href], + label[for], + select:not(:disabled), + summary { + cursor: pointer; + } /* 스크롤바·선택·캐럿 — ABAP_OPENCODE 와 같은 얇은 오버레이 스타일 */ * { scrollbar-width: thin; diff --git a/4_rust_tauri/src-tauri/Cargo.toml b/4_rust_tauri/src-tauri/Cargo.toml index 6847f13..95b6c03 100644 --- a/4_rust_tauri/src-tauri/Cargo.toml +++ b/4_rust_tauri/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ png = "0.17" [target.'cfg(windows)'.dependencies] windows = { version = "0.61.3", features = [ "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_DataExchange", diff --git a/4_rust_tauri/src-tauri/src/bridge/commands.rs b/4_rust_tauri/src-tauri/src/bridge/commands.rs index 90795c8..0044f2d 100644 --- a/4_rust_tauri/src-tauri/src/bridge/commands.rs +++ b/4_rust_tauri/src-tauri/src/bridge/commands.rs @@ -18,6 +18,8 @@ use crate::shell::{paste, paste::PasteState, window}; pub struct Routes { pub current: String, pub last_snap: String, + /// 마지막으로 열었던 기존 대화(`/snap/s/`). 캡쳐를 기존 대화에 붙일 때 씀. + pub last_session: Option, } impl Default for Routes { @@ -26,6 +28,7 @@ impl Default for Routes { Self { current: "/snap".into(), last_snap: "/snap".into(), + last_session: None, } } } @@ -35,6 +38,9 @@ impl Routes { if path == "/snap" || path.starts_with("/snap/") { path.clone_into(&mut self.last_snap); } + if path.starts_with("/snap/s/") { + self.last_session = Some(path.to_string()); + } path.clone_into(&mut self.current); } } @@ -67,7 +73,8 @@ pub enum SnippetStage { #[tauri::command] pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> { let (width, height) = match stage { - SnippetStage::Search => (640.0, 84.0), + // 제목줄 h-8(32) + 검색줄 h-14(56) = 88. 이보다 작으면 프레임 overflow-auto 가 스크롤바를 띄움. + SnippetStage::Search => (640.0, 88.0), SnippetStage::Results => (640.0, 440.0), SnippetStage::Preview => (840.0, 520.0), SnippetStage::Editor => (960.0, 600.0), diff --git a/4_rust_tauri/src-tauri/src/bridge/mod.rs b/4_rust_tauri/src-tauri/src/bridge/mod.rs index 5365184..46ef25a 100644 --- a/4_rust_tauri/src-tauri/src/bridge/mod.rs +++ b/4_rust_tauri/src-tauri/src/bridge/mod.rs @@ -30,9 +30,10 @@ pub enum Push { #[serde(rename = "paste.target")] PasteTarget { name: String, app: String }, - /// 영역 캡쳐 PNG를 프론트의 새 대화 Composer에 붙임. + /// 영역 캡쳐 PNG를 지정한 대화 Composer에 붙임. #[serde(rename = "capture.image")] CaptureImage { + path: String, #[serde(rename = "dataUrl")] data_url: String, }, diff --git a/4_rust_tauri/src-tauri/src/lib.rs b/4_rust_tauri/src-tauri/src/lib.rs index 17a796e..d18df54 100644 --- a/4_rust_tauri/src-tauri/src/lib.rs +++ b/4_rust_tauri/src-tauri/src/lib.rs @@ -18,15 +18,29 @@ use shell::window; const HOTKEYS: &[HotkeySpec] = &[ HotkeySpec { id: "snap", - accelerator: "Ctrl+Shift+8", + accelerator: "Ctrl+Alt+W", }, HotkeySpec { id: "snippet", - accelerator: "Ctrl+Shift+7", + accelerator: "Ctrl+Alt+Q", }, + // 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화). + // 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역. HotkeySpec { id: "capture", - accelerator: "Ctrl+Shift+9", + accelerator: "Ctrl+Alt+A", + }, + HotkeySpec { + id: "capture_area", + accelerator: "Ctrl+Alt+S", + }, + HotkeySpec { + id: "capture_last", + accelerator: "Ctrl+Alt+Z", + }, + HotkeySpec { + id: "capture_area_last", + accelerator: "Ctrl+Alt+X", }, ]; @@ -149,6 +163,35 @@ fn show_palette(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화. +fn capture_target(id: &str, last_session: Option<&str>) -> String { + if id.ends_with("_last") { + last_session.unwrap_or("/snap/new").to_string() + } else { + "/snap/new".to_string() + } +} + +/// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부. +fn on_captured(app: AppHandle, target: String, data_url: String) { + if let Err(error) = show_palette(&app) { + eprintln!("[capture] 창 소환 실패: {error}"); + } + bridge::push( + &app, + Push::Navigate { + path: target.clone(), + }, + ); + bridge::push( + &app, + Push::CaptureImage { + path: target, + data_url, + }, + ); +} + /// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함. fn on_hotkey(app: &AppHandle, id: &str) { match id { @@ -162,17 +205,20 @@ fn on_hotkey(app: &AppHandle, id: &str) { ); } - "capture" => { - shell::capture::start(app.clone(), |app, data_url| { - let _ = show_palette(&app); - bridge::push( - &app, - Push::Navigate { - path: "/snap/new".into(), - }, - ); - bridge::push(&app, Push::CaptureImage { data_url }); - }); + "capture" | "capture_area" | "capture_last" | "capture_area_last" => { + let target = app + .state::() + .lock() + .map(|routes| capture_target(id, routes.last_session.as_deref())) + .unwrap_or_else(|_| "/snap/new".to_string()); + let deliver = + move |app: AppHandle, data_url: String| on_captured(app, target, data_url); + if id.starts_with("capture_area") { + shell::capture::start(app.clone(), deliver); + } else { + let own_hwnd = window::native_handle(app); + shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver); + } } // 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다. @@ -199,7 +245,7 @@ fn on_hotkey(app: &AppHandle, id: &str) { #[cfg(test)] mod tests { - use super::{chat_toggle_target, commands::Routes}; + use super::{capture_target, chat_toggle_target, commands::Routes}; #[test] fn 챗봇_토글은_현재_화면과_창_표시에_따라_결정한다() { @@ -219,13 +265,28 @@ mod tests { } #[test] - fn 마지막_챗봇_위치는_챗봇_화면에서만_바뀐다() { + fn 마지막_기존_대화는_새_대화나_다른_화면으로_가도_유지된다() { let mut routes = Routes::default(); - routes.report("/snap/session-1"); - routes.report("/snippet"); + routes.report("/snap/s/session-1"); + routes.report("/snap/new"); routes.report("/files"); assert_eq!(routes.current, "/files"); - assert_eq!(routes.last_snap, "/snap/session-1"); + assert_eq!(routes.last_snap, "/snap/new"); + assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1")); + } + + #[test] + fn 캡쳐_핫키에_따라_새_대화나_마지막_기존_대화를_고른다() { + assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new"); + assert_eq!( + capture_target("capture_area", Some("/snap/s/last")), + "/snap/new" + ); + assert_eq!( + capture_target("capture_last", Some("/snap/s/last")), + "/snap/s/last" + ); + assert_eq!(capture_target("capture_area_last", None), "/snap/new"); } } diff --git a/4_rust_tauri/src-tauri/src/shell/capture.rs b/4_rust_tauri/src-tauri/src/shell/capture.rs index 670d781..a0774ad 100644 --- a/4_rust_tauri/src-tauri/src/shell/capture.rs +++ b/4_rust_tauri/src-tauri/src/shell/capture.rs @@ -1,5 +1,6 @@ //! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판. +use std::ffi::c_void; use std::io::Cursor; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -8,27 +9,30 @@ use base64::Engine; use tauri::AppHandle; use windows::core::{w, PCWSTR}; use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}; use windows::Win32::Graphics::Gdi::{ BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC, DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC, - SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ, PAINTSTRUCT, - SRCCOPY, + SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, HGDIOBJ, + PAINTSTRUCT, SRCCOPY, }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus}; use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetMessageW, - GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW, + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetForegroundWindow, + GetMessageW, GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW, SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow, TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA, - MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOW, - WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN, - WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, WNDCLASSW, - WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP, + LWA_COLORKEY, MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, SW_SHOW, WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, + WM_ERASEBKGND, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, + WM_NCDESTROY, WM_PAINT, WNDCLASSW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP, }; const MIN_SELECTION: i32 = 4; const OVERLAY_ALPHA: u8 = 115; +// 선택 영역에만 칠해 layered window에서 완전히 투명하게 만드는 색. +const SELECTION_COLOR_KEY: COLORREF = COLORREF(0x00ff00ff); // Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임. const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814); static CAPTURING: AtomicBool = AtomicBool::new(false); @@ -54,17 +58,36 @@ fn selection_rect(start: (i32, i32), end: (i32, i32), origin: (i32, i32)) -> Opt }) } -/// 이미 캡쳐 중이면 두 번째 핫키는 무시함. +/// 드래그로 영역 골라서 캡쳐. 이미 캡쳐 중이면 두 번째 핫키는 무시함. pub fn start(app: AppHandle, on_captured: F) where F: FnOnce(AppHandle, String) + Send + 'static, +{ + spawn_capture(app, on_captured, run_overlay); +} + +/// 핫키 누른 순간 맨 앞에 떠 있는 창을 통째로 캡쳐. 앱 종류 안 가림(SAP·Eclipse·브라우저 다 됨). +/// 자기 창(팔레트)이 맨 앞이면 아무것도 안 함. +pub fn start_foreground_window(app: AppHandle, excluded_hwnd: Option, on_captured: F) +where + F: FnOnce(AppHandle, String) + Send + 'static, +{ + spawn_capture(app, on_captured, move || { + foreground_window_rect(excluded_hwnd) + }); +} + +fn spawn_capture(app: AppHandle, on_captured: F, pick_rect: P) +where + F: FnOnce(AppHandle, String) + Send + 'static, + P: FnOnce() -> Result, String> + Send + 'static, { if CAPTURING.swap(true, Ordering::AcqRel) { return; } std::thread::spawn(move || { - let result = run_overlay().and_then(|rect| match rect { + let result = pick_rect().and_then(|rect| match rect { Some(rect) => capture_png_data_url(rect), None => Ok(None), }); @@ -77,10 +100,59 @@ where }); } +struct PaintBuffer { + dc: HDC, + bitmap: HBITMAP, + previous: HGDIOBJ, +} + +impl PaintBuffer { + unsafe fn new(hwnd: HWND, width: i32, height: i32) -> Option { + let window_dc = GetDC(Some(hwnd)); + if window_dc.is_invalid() { + return None; + } + let dc = CreateCompatibleDC(Some(window_dc)); + let bitmap = CreateCompatibleBitmap(window_dc, width, height); + let _ = ReleaseDC(Some(hwnd), window_dc); + if dc.is_invalid() || bitmap.is_invalid() { + if !dc.is_invalid() { + let _ = DeleteDC(dc); + } + if !bitmap.is_invalid() { + let _ = DeleteObject(HGDIOBJ(bitmap.0)); + } + return None; + } + let previous = SelectObject(dc, HGDIOBJ(bitmap.0)); + if previous.is_invalid() { + let _ = DeleteObject(HGDIOBJ(bitmap.0)); + let _ = DeleteDC(dc); + return None; + } + Some(Self { + dc, + bitmap, + previous, + }) + } +} + +impl Drop for PaintBuffer { + fn drop(&mut self) { + unsafe { + SelectObject(self.dc, self.previous); + let _ = DeleteObject(HGDIOBJ(self.bitmap.0)); + let _ = DeleteDC(self.dc); + } + } +} + struct OverlayState { origin: (i32, i32), start: Option<(i32, i32)>, current: Option<(i32, i32)>, + buffer: Option, sender: mpsc::Sender>, finished: bool, } @@ -106,6 +178,24 @@ fn mouse_point(lparam: LPARAM) -> (i32, i32) { ((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32) } +fn selection_client_rect(start: (i32, i32), end: (i32, i32)) -> RECT { + RECT { + left: start.0.min(end.0), + top: start.1.min(end.1), + right: start.0.max(end.0), + bottom: start.1.max(end.1), + } +} + +fn union_rect(a: RECT, b: RECT) -> RECT { + RECT { + left: a.left.min(b.left), + top: a.top.min(b.top), + right: a.right.max(b.right), + bottom: a.bottom.max(b.bottom), + } +} + fn inset_rect(rect: RECT, amount: i32) -> Option { let inset = RECT { left: rect.left + amount, @@ -116,6 +206,28 @@ fn inset_rect(rect: RECT, amount: i32) -> Option { (inset.left < inset.right && inset.top < inset.bottom).then_some(inset) } +unsafe fn draw_overlay(dc: HDC, background: &RECT, selected: Option) { + let shade = CreateSolidBrush(COLORREF(0x000000)); + FillRect(dc, background, shade); + let _ = DeleteObject(HGDIOBJ(shade.0)); + + let Some(selected) = selected else { return }; + let clear = CreateSolidBrush(SELECTION_COLOR_KEY); + FillRect(dc, &selected, clear); + let _ = DeleteObject(HGDIOBJ(clear.0)); + + let white = CreateSolidBrush(COLORREF(0x00ffffff)); + let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR); + FrameRect(dc, &selected, white); + for amount in 1..4 { + if let Some(border) = inset_rect(selected, amount) { + FrameRect(dc, &border, accent); + } + } + let _ = DeleteObject(HGDIOBJ(white.0)); + let _ = DeleteObject(HGDIOBJ(accent.0)); +} + unsafe extern "system" fn overlay_proc( hwnd: HWND, message: u32, @@ -141,9 +253,13 @@ unsafe extern "system" fn overlay_proc( } WM_MOUSEMOVE => { if let Some(state) = state { - if state.start.is_some() { - state.current = Some(mouse_point(lparam)); - let _ = InvalidateRect(Some(hwnd), None, false); + if let Some(start) = state.start { + let current = mouse_point(lparam); + let old = selection_client_rect(start, state.current.unwrap_or(start)); + let new = selection_client_rect(start, current); + state.current = Some(current); + let dirty = union_rect(old, new); + let _ = InvalidateRect(Some(hwnd), Some(&dirty), false); } } LRESULT(0) @@ -172,33 +288,34 @@ unsafe extern "system" fn overlay_proc( WM_PAINT => { let mut paint = PAINTSTRUCT::default(); let dc = BeginPaint(hwnd, &mut paint); - let mut client = RECT::default(); - let _ = GetClientRect(hwnd, &mut client); - let shade = CreateSolidBrush(COLORREF(0x000000)); - FillRect(dc, &client, shade); - if let Some(state) = state { - if let (Some(start), Some(end)) = (state.start, state.current) { - let selected = RECT { - left: start.0.min(end.0), - top: start.1.min(end.1), - right: start.0.max(end.0), - bottom: start.1.max(end.1), - }; - // 반투명 오버레이에서도 경계가 묻히지 않게 흰색 외곽선과 - // 포인트색 안쪽선을 겹쳐 4px 테두리로 그림. - let white = CreateSolidBrush(COLORREF(0x00ffffff)); - let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR); - FrameRect(dc, &selected, white); - for amount in 1..4 { - if let Some(border) = inset_rect(selected, amount) { - FrameRect(dc, &border, accent); - } - } - let _ = DeleteObject(HGDIOBJ(white.0)); - let _ = DeleteObject(HGDIOBJ(accent.0)); + let selected = state.as_ref().and_then(|state| { + state + .start + .zip(state.current) + .map(|(start, end)| selection_client_rect(start, end)) + }); + + if let Some(buffer) = state.as_ref().and_then(|state| state.buffer.as_ref()) { + draw_overlay(buffer.dc, &paint.rcPaint, selected); + let width = paint.rcPaint.right - paint.rcPaint.left; + let height = paint.rcPaint.bottom - paint.rcPaint.top; + if width > 0 && height > 0 { + let _ = BitBlt( + dc, + paint.rcPaint.left, + paint.rcPaint.top, + width, + height, + Some(buffer.dc), + paint.rcPaint.left, + paint.rcPaint.top, + SRCCOPY, + ); } + } else { + draw_overlay(dc, &paint.rcPaint, selected); } - let _ = DeleteObject(HGDIOBJ(shade.0)); + let _ = EndPaint(hwnd, &paint); LRESULT(0) } @@ -246,6 +363,7 @@ fn run_overlay() -> Result, String> { origin: (x, y), start: None, current: None, + buffer: None, sender, finished: false, }); @@ -270,8 +388,13 @@ fn run_overlay() -> Result, String> { return Err(error.to_string()); } }; - if let Err(error) = SetLayeredWindowAttributes(hwnd, COLORREF(0), OVERLAY_ALPHA, LWA_ALPHA) - { + (*state_ptr).buffer = PaintBuffer::new(hwnd, width, height); + if let Err(error) = SetLayeredWindowAttributes( + hwnd, + SELECTION_COLOR_KEY, + OVERLAY_ALPHA, + LWA_ALPHA | LWA_COLORKEY, + ) { let _ = DestroyWindow(hwnd); return Err(error.to_string()); } @@ -288,6 +411,30 @@ fn run_overlay() -> Result, String> { Ok(receiver.recv().ok().flatten()) } +/// 맨 앞 창의 화면 좌표. Win11 은 GetWindowRect 가 그림자 여백까지 잡아서 DWM 확장 프레임 경계를 씀. +fn foreground_window_rect(excluded_hwnd: Option) -> Result, String> { + let window = unsafe { GetForegroundWindow() }; + if window.0.is_null() || excluded_hwnd == Some(window.0 as isize) { + return Ok(None); + } + let mut bounds = RECT::default(); + unsafe { + DwmGetWindowAttribute( + window, + DWMWA_EXTENDED_FRAME_BOUNDS, + &mut bounds as *mut RECT as *mut c_void, + std::mem::size_of::() as u32, + ) + } + .map_err(|error| format!("창 경계를 못 얻었어: {error}"))?; + Ok(Some(CaptureRect { + x: bounds.left, + y: bounds.top, + width: bounds.right - bounds.left, + height: bounds.bottom - bounds.top, + })) +} + fn capture_png_data_url(rect: CaptureRect) -> Result, String> { if rect.width <= 0 || rect.height <= 0 { return Ok(None); @@ -413,4 +560,21 @@ mod tests { assert_eq!((inset.right, inset.bottom), (27, 47)); assert!(inset_rect(rect, 10).is_none()); } + + #[test] + fn 이동한_선택_영역의_합집합만_다시_그린다() { + let old = selection_client_rect((100, 100), (300, 250)); + let new = selection_client_rect((100, 100), (320, 220)); + let dirty = union_rect(old, new); + + assert_eq!( + dirty, + RECT { + left: 100, + top: 100, + right: 320, + bottom: 250, + } + ); + } } diff --git a/4_rust_tauri/src-tauri/src/shell/capture_stub.rs b/4_rust_tauri/src-tauri/src/shell/capture_stub.rs index c5edeec..d6eae09 100644 --- a/4_rust_tauri/src-tauri/src/shell/capture_stub.rs +++ b/4_rust_tauri/src-tauri/src/shell/capture_stub.rs @@ -8,3 +8,10 @@ where { eprintln!("[capture] 영역 캡처는 Windows 에서만 됨"); } + +pub fn start_foreground_window(_app: AppHandle, _excluded_hwnd: Option, _on_captured: F) +where + F: FnOnce(AppHandle, String) + Send + 'static, +{ + eprintln!("[capture] 창 캡처는 Windows 에서만 됨"); +} diff --git a/4_rust_tauri/src-tauri/src/shell/window.rs b/4_rust_tauri/src-tauri/src/shell/window.rs index a3cdec2..c6c7023 100644 --- a/4_rust_tauri/src-tauri/src/shell/window.rs +++ b/4_rust_tauri/src-tauri/src/shell/window.rs @@ -11,6 +11,17 @@ use std::sync::Mutex; use tauri::{ AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow, }; +#[cfg(windows)] +use windows::Win32::Foundation::HWND; +#[cfg(windows)] +use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId}; +#[cfg(windows)] +use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; +#[cfg(windows)] +use windows::Win32::UI::WindowsAndMessaging::{ + BringWindowToTop, GetForegroundWindow, GetWindowThreadProcessId, SetForegroundWindow, + ShowWindow, SW_RESTORE, +}; /// tauri.conf.json 의 창 label 과 같아야 함. const MAIN: &str = "main"; @@ -19,7 +30,6 @@ pub fn main_window(app: &AppHandle) -> Option { app.get_webview_window(MAIN) } -/// 창 표시 + 포커스. (핫키/트레이 소환용) /// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND). /// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함. pub fn native_handle(app: &AppHandle) -> Option { @@ -36,10 +46,45 @@ pub fn native_handle(app: &AppHandle) -> Option { } } +/// 창 표시 + 포커스. (핫키/트레이 소환용) +/// Windows 에선 캡처 오버레이가 돌려준 foreground 를 현재 입력 큐에 잠시 붙여 +/// 이미 보이던 창도 뒤에 남지 않게 직접 활성화함(Tauri set_focus 만으론 외부 앱이 다시 가져감). pub fn show(app: &AppHandle) -> tauri::Result<()> { if let Some(w) = main_window(app) { w.show()?; - w.set_focus()?; + let _ = w.set_focus(); + #[cfg(windows)] + force_foreground(&w)?; + } + Ok(()) +} + +#[cfg(windows)] +fn force_foreground(w: &WebviewWindow) -> tauri::Result<()> { + let raw = w.hwnd()?; + let hwnd = HWND(raw.0 as *mut std::ffi::c_void); + unsafe { + let foreground = GetForegroundWindow(); + let current_thread = GetCurrentThreadId(); + let foreground_thread = if foreground.0.is_null() { + 0 + } else { + GetWindowThreadProcessId(foreground, None) + }; + let attached = foreground_thread != 0 + && foreground_thread != current_thread + && AttachThreadInput(current_thread, foreground_thread, true).as_bool(); + + let _ = ShowWindow(hwnd, SW_RESTORE); + let _ = BringWindowToTop(hwnd); + if !SetForegroundWindow(hwnd).as_bool() { + eprintln!("[window] foreground 활성화 실패"); + } + let _ = SetFocus(Some(hwnd)); + + if attached { + let _ = AttachThreadInput(current_thread, foreground_thread, false); + } } Ok(()) } diff --git a/docs/feedback/2026-09-17-hotkey-one-hand.md b/docs/feedback/2026-09-17-hotkey-one-hand.md new file mode 100644 index 0000000..891dee0 --- /dev/null +++ b/docs/feedback/2026-09-17-hotkey-one-hand.md @@ -0,0 +1,20 @@ +# 2026-09-17 단축키는 왼손 한손으로 + +## 원문 + +> 단축키를 숫자로 하지말고 왼손 한손으로 끝내고 싶음. 지금은 컨트롤 쉬프트는 왼손 숫자는 오른손이라 불편함 + +## 왜 틀렸나 + +`Ctrl+Shift+7/8/9` 는 왼손으로 modifier 잡고 오른손으로 숫자 눌러야 해서 두 손이 필요함. SAP GUI·Eclipse 에서 마우스 잡은 채로 호출하는 게 이 앱의 핵심 흐름이라, 한손(왼손)으로 끝나야 함. + +## 앞으로 + +- 전역 단축키는 **왼손 범위 글자**(Q W E R A S D F Z X C V)만 씀. 숫자·오른손 글자 금지. +- modifier 는 `Ctrl+Alt`. 2026-09-17 실제 목록으로 확인한 결과: + - `Ctrl+Shift+글자`: Eclipse/ADT 가 거의 다 씀(A 오브젝트 열기, R/E/W/F/L/G…). + - `Alt+Shift+글자`: 역시 Eclipse 가 씀 — A 블록선택, S Source, W Show In, Z Surround With, X Run As, D Debug As, R Rename, T Refactor, Q Show View. SAP GUI 도 Alt+Shift+V(디버거 변수). + - `Ctrl+Alt+글자`: Eclipse 는 G/H/M/N/B 만, SAP GUI 는 L/M/P 만 씀 → Q/W/A/S/Z/X 는 비어 있음. + - 새 키 잡을 땐 Eclipse 에서 `Ctrl+Shift+L` 로 실제 바인딩 목록 보고 겹치는지 확인할 것. 기억으로 판단 금지. +- 현재 배정: `Ctrl+Alt+Q` 스니펫 / `Ctrl+Alt+W` 챗 토글 / `Ctrl+Alt+A` 맨 앞 창 캡쳐→새 대화 / `Ctrl+Alt+S` 드래그 캡쳐→새 대화 / `Ctrl+Alt+Z` 맨 앞 창 캡쳐→마지막 대화 / `Ctrl+Alt+X` 드래그 캡쳐→마지막 대화. 윗줄 = 여는 것, A/S = 새 대화에 찍기, Z/X = 기존 대화에 찍기. +- 진실원천은 `4_rust_tauri/src-tauri/src/lib.rs` 의 `HOTKEYS`. 새 단축키 추가할 때도 이 규칙 따름. diff --git a/docs/troubleshootings/2026-09-16-capture-focus-and-destination.md b/docs/troubleshootings/2026-09-16-capture-focus-and-destination.md new file mode 100644 index 0000000..3996eb0 --- /dev/null +++ b/docs/troubleshootings/2026-09-16-capture-focus-and-destination.md @@ -0,0 +1,25 @@ +# 캡처가 뒤쪽 창에 붙고 대상 대화를 고를 수 없음 + +## 증상 + +- 새 대화 화면이 이미 열려 있어도 캡처 완료 뒤 CodeAssist가 앞으로 오지 않고 뒤에서 이미지만 첨부됐음. +- 캡처 이미지는 항상 새 대화로 보내져 기존 대화에 붙일 방법이 없었음. + +## 원인 + +- 캡처 완료 흐름이 `window.show()`와 Tauri `set_focus()` 결과에만 기대고 있었음. Win32 캡처 오버레이가 닫힌 뒤 외부 앱이 다시 foreground를 가져가면 이미 보이는 CodeAssist 창의 활성화가 보장되지 않았음. +- native 흐름이 대상 route를 무조건 `/snap/new`로 보냈음. +- 프론트의 pending 캡처는 대상 route 정보가 없어 `NewChatPage`만 소비하도록 막아둔 상태였음. + +## 수정 + +- 창을 소환할 때 현재 입력 thread를 foreground 입력 thread에 잠시 연결하고 Win32 `BringWindowToTop`, `SetForegroundWindow`, `SetFocus`로 메인 창을 직접 활성화함. +- `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 함. 기존 대화가 없으면 새 대화로 대체함. +- `capture.image`에 대상 `path`를 추가하고, path가 같은 Composer만 pending 이미지를 소비하게 바꿈. + +## 확인 + +- 대상 route 선택과 route별 pending 소비 회귀 테스트를 RED→GREEN으로 확인함. +- frontend 전체 217개 테스트, lint, build와 Rust 13개 테스트, build를 실행함. +- 데스크톱 앱에서 `/snap/new` 도착과 캡처 첨부 노출을 확인함. +- 자동화 환경이 Windows 창 focus 전환을 지원하지 않아 외부 앱 위로 실제 foreground가 바뀌는 장면은 자동 확인하지 못했음. 해당 한 항목은 실제 단축키 수동 확인이 남음. diff --git a/docs/troubleshootings/2026-09-16-capture-selection-flicker.md b/docs/troubleshootings/2026-09-16-capture-selection-flicker.md new file mode 100644 index 0000000..190b72e --- /dev/null +++ b/docs/troubleshootings/2026-09-16-capture-selection-flicker.md @@ -0,0 +1,25 @@ +# 캡처 선택 영역이 드래그 중 번쩍임 + +## 증상 + +- 캡처 범위를 드래그하면 원래 화면으로 보이는 선택 영역이 계속 번쩍였음. +- 캡처 결과가 아니라 드래그 중 오버레이 표시에서만 발생했음. + +## 원인 + +- `WM_MOUSEMOVE`마다 `InvalidateRect`에 영역을 주지 않아 전체 가상 화면을 다시 그렸음. +- `WM_PAINT`가 실제 화면 DC에 검정 배경, 투명 color key, 테두리를 차례로 직접 그려 중간 상태가 노출됐음. +- 멀티모니터 전체 면적을 매번 다시 칠해 불필요한 GDI 작업도 컸음. + +## 수정 + +- 이전 선택 사각형과 새 선택 사각형의 합집합만 무효화함. +- 오버레이 크기의 호환 bitmap과 memory DC를 한 번 만들고 창이 닫힐 때 해제함. +- memory DC에서 배경·투명 영역·테두리를 완성한 뒤 dirty 영역만 `BitBlt` 한 번으로 화면에 복사함. +- memory DC를 만들지 못하면 기존 화면 DC 그리기로 대체함. + +## 확인 + +- 역방향 드래그를 포함한 선택 영역 합집합 계산 회귀 테스트를 추가함. +- Rust 테스트 14개와 build를 통과함. +- 요청에 따라 computer-use는 사용하지 않았고, 실제 드래그 화면 확인은 사용자 수동 확인으로 남김. diff --git a/specs/002-capture-to-chat/contracts/bridge-capture.md b/specs/002-capture-to-chat/contracts/bridge-capture.md index 0e3cdc9..b42211a 100644 --- a/specs/002-capture-to-chat/contracts/bridge-capture.md +++ b/specs/002-capture-to-chat/contracts/bridge-capture.md @@ -6,10 +6,11 @@ | 방향 | 메시지 | 처리 | |---|---|---| -| C#→JS | `{type:"capture.image", dataUrl}` | 프론트가 캡쳐 이미지를 챗 입력창 첨부로 받음 | +| C#→JS | `{type:"capture.image", path, dataUrl}` | 프론트가 캡쳐 이미지를 지정된 챗 입력창 첨부로 받음 | +- `path`: 캡쳐를 받을 route(`/snap/new` 또는 `/snap/s/:id`). - `dataUrl`: `data:image/png;base64,...` (PNG). -- App 캡쳐 완료 순서: `ShowPalette()` → `NavigateApp("/snap/new")` → `SendCaptureImage(dataUrl)`. (navigate·소환은 기존 배선.) +- App 캡쳐 완료 순서: 창 소환 → 대상 route로 이동 → `{type:"capture.image", path, dataUrl}` 전송. - 기존 메시지(navigate·paste.target·window.*·snippets.* 등)와 같은 리스너 허브에서 `type`으로 분기. ## C# 표면 (신규/확장) @@ -22,14 +23,15 @@ ## 프론트 계약 (`bridgeNavigate.ts`) ```ts -// 마지막 캡쳐 이미지 dataUrl 을 1회 소비(마운트 레이스 대비). 없으면 "". -export function consumePendingCaptureImage(): string -// capture.image 수신 → pendingCaptureImage 갱신 + window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: { dataUrl } })) +// path가 일치하는 마지막 캡쳐 이미지를 1회 소비하고, 다르면 pending을 유지함. +export function consumePendingCaptureImage(path: string): string +// capture.image 수신 → {path, dataUrl} 보관 + bridge:captureImage 이벤트 발생 ``` ## 프론트 계약 (`Composer.tsx`) -- 상태 `imageAttachments: string[]`(dataUrl). 마운트 시 `consumePendingCaptureImage()` push + `bridge:captureImage` 구독 push. X 제거. (data-model.md 규칙.) +- `capturePath`와 메시지 `path`가 같은 Composer만 pending 이미지를 소비함. 네비게이션 직전의 다른 Composer가 이미지를 훔치지 않는다. +- 새 대화와 기존 대화 모두 캡쳐 이미지를 누적하고 X로 제거할 수 있다. - v1 전송: 텍스트만. 이미지 첨부는 미전송 + 전송 후 정리. ## 순수 브라우저(dev) diff --git a/specs/002-capture-to-chat/spec.md b/specs/002-capture-to-chat/spec.md index cc5f283..387b7ca 100644 --- a/specs/002-capture-to-chat/spec.md +++ b/specs/002-capture-to-chat/spec.md @@ -14,7 +14,7 @@ 작업 중 화면 어디든 전역 핫키로 사각형을 그려 그 영역을 캡쳐하면, 그 이미지가 곧바로 챗 입력창에 첨부로 붙는다. 나중에 "이미지 놓고 AI랑 얘기"하려는 건데, **이번 v1은 캡쳐해서 챗에 붙이는 데까지**만 한다. 실제 분석(비전 LLM)·웹서치는 백엔드가 준비되면 그 위에 얹는다. -**한 줄 흐름**: 핫키 → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 챗 새 대화에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑). +**한 줄 흐름**: 새 대화 핫키(`Ctrl+Shift+9`) 또는 마지막 대화 핫키(`Ctrl+Shift+0`) → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 선택한 챗 입력창에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑). --- @@ -22,7 +22,7 @@ ### User Story 1 - 화면 캡쳐해서 챗에 첨부 (Priority: P1) -에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키(`Ctrl+Shift+9`)를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. 원하는 영역을 마우스로 드래그해 사각형으로 잡고 놓으면, 앱 창이 떠서 새 대화 화면으로 가고 그 캡쳐 이미지가 입력창에 첨부(썸네일)로 붙어 있다. 이제 질문을 타이핑하면 된다. +에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화에 붙이고 기존 대화가 없으면 새 대화로 간다. 영역을 마우스로 드래그해 놓으면 앱 창이 앞으로 뜨고 선택한 대화 입력창에 캡쳐 이미지가 첨부돼 있다. **Why this priority**: 이게 이 기능의 전부(v1). 캡쳐→첨부가 매끄럽게 되면 나중에 백엔드만 붙이면 "이미지로 AI와 대화"가 완성됨. @@ -31,11 +31,12 @@ **Acceptance Scenarios**: 1. **Given** 아무 창이 떠있음, **When** `Ctrl+Shift+9`, **Then** 모든 모니터를 덮는 어두운 캡쳐 오버레이가 뜬다. -2. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다. -3. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음). -4. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다. -5. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다. -6. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지). +2. **Given** 캡쳐 오버레이에서 영역을 드래그 중임, **When** 선택 사각형이 바뀜, **Then** 사각형 내부는 원래 화면 밝기로 보이고 바깥만 어둡게 유지된다. +3. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다. +4. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음). +5. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다. +6. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다. +7. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지). --- @@ -56,12 +57,13 @@ **캡쳐 트리거·오버레이** - **FR-001**: 전역 핫키 `Ctrl+Shift+9`로 화면 캡쳐 모드를 켤 수 있어야 한다. 기존 핫키(스니펫 `Ctrl+Shift+7`, 챗 `Ctrl+Shift+8`)와 별개다. -- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 그 위에서 마우스 드래그로 선택 사각형을 그린다. +- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 마우스 드래그 중 선택 사각형 내부는 원래 화면 밝기로 투명하게 보여준다. - **FR-003**: `Esc`, 또는 유효하지 않은 선택(드래그 없이 클릭/너무 작은 영역)이면 아무것도 캡쳐하지 않고 오버레이를 닫는다. **캡쳐·전달** - **FR-004**: 드래그로 확정된 영역의 화면 픽셀을 이미지로 캡쳐한다. -- **FR-005**: 캡쳐 완료 시 앱 창을 소환하고, **새 대화 화면**으로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다. +- **FR-005**: 캡쳐 완료 시 앱 창을 앞으로 소환하고, 사용한 핫키에 맞는 대화로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다. +- **FR-005a**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 하며 기존 대화가 없으면 새 대화로 대체한다. **챗 첨부(끝점)** - **FR-006**: 전달된 이미지는 챗 입력창에 **썸네일 첨부**로 표시된다. @@ -90,7 +92,7 @@ ## Assumptions - **v1 범위 = 캡쳐 파이프라인만.** 이미지를 실은 메시지를 멀티모달 LLM 백엔드로 보내 **비전 분석·응답**하는 것, **웹서치** 툴은 다음 phase(백엔드 계약 나올 때). **전송-with-이미지 payload 배선도 v1 밖** — v1의 전송은 기존 텍스트 그대로. -- **캡쳐 도착지 = 새 대화 화면.** 캡쳐하면 새 챗을 열고 거기에 붙인다(기존 세션에 붙이는 건 v1 밖). +- **캡쳐 도착지**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화. 캡쳐 메시지에 대상 route를 실어 해당 Composer만 이미지를 소비한다. - **재사용**: 기존 멀티핫키 등록, C#→JS 브릿지(SendToWeb/PostWebMessageAsJson), 팔레트 창 소환·navigate, 브릿지 이벤트 패턴(pasteTarget/bridgeNavigate 식 — 마운트 레이스 대비 모듈 보관+이벤트), Composer의 첨부 칩 UI 패턴을 최대한 얹는다. 캡쳐 오버레이 창과 캡쳐 로직만 실질 신규. - **현재 이미지 처리와의 관계**: 지금 이미지 붙여넣기는 클립보드 이력 사이드바로만 감(챗에 "보내는 첨부" 개념 없음). 이 기능이 **챗 입력창 이미지 첨부**를 처음 도입한다. - **ponytail 천장(추후 보완)**: ① 고DPI/모니터별 배율 좌표 보정, ② 큰 영역 캡쳐 시 base64가 커지면 임시파일/blob 전달로 최적화. @@ -99,5 +101,4 @@ - 이미지 비전 분석·AI 판단·응답(멀티모달 백엔드). - 웹서치. -- 이미지를 실은 메시지의 백엔드 전송(payload 포맷 포함). -- 기존(진행 중) 세션에 캡쳐 붙이기, 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리. +- 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.