Compare commits

...
23 Commits
Author SHA1 Message Date
MsystechandClaude Fable 5 5771a824d7 '데이터 필드 배선' → '값 가져오기' — 개발자 말을 걷어낸다
사용자가 "어떻게 사용하는지 모르겠다, 개발자가 아닌 사람도 쓸 수 있어야 한다" 고 했다.
화면을 열어 보니 낱말만 문제가 아니었다.

## 낱말

- 창 제목 '데이터 필드 배선' → <b>'값 가져오기'</b>. 전산실에서 '배선'은 랜선·전원이고,
  '배선 지우기' 는 데이터소스나 쿼리가 지워지는 것처럼 읽힌다(실제로는 이 컨트롤 속성만 빈다).
- 인스펙터 행 라벨 '데이터 필드' → '값 가져오기'. 빈 상태 '(배선 없음)' → <b>'(가져오지 않음)'</b> —
  대화상자의 지우기 버튼과 <b>글자까지 같게</b> 두어, 누르면 무엇이 되는지 미리 읽히게 했다.
- '필드' → '가져올 컬럼'. 앱은 이미 결과 창에서 '컬럼 N개', 배선 실패 문구에서
  '결과에 X 컬럼이 없습니다' 로 컬럼을 쓴다 — 낱말을 하나 더 만들지 않는다.
- '형태 Select/Rows' 를 화면에서 지웠다. .NET DataTable API 이름이라
  <b>SQL 을 아는 사람이 오히려 더 확실히 오해한다</b>(여기의 Select 는 SQL SELECT 가 아니다).
- '필터' → '행 조건'. WHERE 연상을 끊는다 — 서버로 가지 않고 이미 받아 온 표를 거르는 식이라
  NVL·TO_CHAR·|| 가 통하지 않는다. 되는 예와 안 되는 예를 나란히 적었다.

## 화면이 "무엇을 먼저 하나"의 답을 '지우기'로 주고 있었다

네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어 넷 다 채워야 하는 것처럼 보였다.
정작 검증이 요구하는 <b>필수는 둘</b>(데이터소스·컬럼)이고, 그 둘이 화면에서 가장 눈에 안 띄는
자리에 있었다. 게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 였다.

①②③ 으로 다시 세웠다 — ① 가져올 데이터소스 ② 가져올 컬럼, ③ '여러 행 중에서 고르기' 는 접는다.
접는 근거는 실측이다: 조건을 쓴 배선이 <b>0건</b>, 행 번호가 0 인 것이 98%.
기본값이 아닐 때만 접힌 머리에 칩('3번째 행' · '조건 있음')을 띄워 숨지 않게 했다.
초기 포커스도 다음 할 일로 보낸다.

## 첫 화면이 굳이 어려운 쪽으로 열리고 있었다

새 배선이 레거시 편집기와 같은 Select 형으로 시작해서, 처음 여는 사람이
<b>평생 안 쓸 개념(조건)</b>을 먼저 봤다. 새 배선을 Rows 형으로 바꿨다.

<b>동작은 같다.</b> 조건이 비면 Select("")(0) 과 Rows(0) 이 같은 행을 가리키고,
해석기 자신이 그 등가를 코드로 인정한다 — DataTableFieldPicker 는 조건이 비어 있으면
필터 경로를 아예 타지 않는다. 기존 값의 형태는 그대로 유지한다(원문을 안 건드리는 것이
왕복 무손실의 조건이다).

## 사용자를 창 밖으로 내보내던 문구

"필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다" —
이 창은 <b>모달이라 밖이 잠겨 있다</b>. 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로
그 문구는 퇴행을 설명하는 문장이었다.

이제 데이터소스를 고르면 그 조회 결과의 컬럼 이름을 보여 준다. 실행은 이 창이 하지 않는다 —
호출부가 MDataTableRunner 를 함수로 감싸 넘기므로 접속·치환·캐시·실패 처리가 한 군데에 남는다.
창은 데이터소스별로 <b>한 번만</b> 묻는다(Refresh 가 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다).
못 읽으면 <b>왜</b> 못 읽는지 말하고 직접 입력 칸을 남긴다.

## 원문은 없애지 않고 접었다

'저장될 값'에 MDataTable1.Select("")(0).Item("") 이 큰 자리로 보이던 것을
사람 말 한 줄로 바꿨다 — "서식을 열면 — MDataTable2 조회 결과의 첫 행에서 ALGYON 값을
가져와 이 칸에 넣습니다." 원문은 접힌 '저장 형식' 줄로 내리고 복사 버튼을 붙였다.

없애지는 않았다. 인스펙터 툴팁·진단 덤프·레거시 문서가 전부 이 표기로 이야기되고,
이 속성에는 자유 텍스트 편집 경로가 없어 무엇이 저장되는지 볼 수 있는 곳이 이 줄뿐이다.

## 곁가지로 고친 조용한 결함 둘

- <b>행 번호가 조용히 0 이 됐다</b>. 전각 '1' 을 넣으면 파싱 실패가 그대로 0 이 되어
  아무 말 없이 첫 행을 가리켰다. 이제 이유를 말하고 확인을 막는다.
- <b>형태를 바꾸면 조건이 조용히 사라졌다</b>. Rows 조립은 Filter 를 아예 쓰지 않는데
  화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다. 다시 열면 영구히 없어진다.
  이제 명시적 버튼이고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
- 데이터소스가 0개일 때 '데이터소스를 선택하세요'(따를 수 없는 지시) 대신 다음 행동을 말한다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과 (옛 문구를 기대한 검사 4건도 새 낱말로 갱신)
- --dialog-shots 69장 전건 통과 — 다크·라이트 양쪽을 눈으로 확인
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- <b>저장되는 문자열은 한 글자도 바꾸지 않았다</b> — 화면 문구와 배치만 바뀐다

## 남은 것

컬럼 목록과 값 확인은 DB 접속이 있어야 동작한다. 오프라인 설계에서는 여전히 직접 입력이고,
그때 컬럼명 대소문자 함정이 남는다. '가져올 값 확인'(현재 환자로 실제 한 칸을 뽑아 보여 주기)은
이번에 넣지 않았다 — 환자 세션까지 이 창에 들이는 일이라 별건이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:16:55 +09:00
MsystechandClaude Fable 5 592275fa97 미리보기 툴바를 아이콘으로 — 버튼 여덟 개가 글자 벽이었다
인쇄… / 새로 고침 / 인쇄될 것만 / 환자 선택… / 환자 해제 / 데이터소스… / 누른 것 초기화 /
- / 100% / + 가 전부 글자로 한 줄에 늘어서 있었다. 무엇이 명령이고 무엇이 상태인지
구분되지 않고, 이름이 긴 것들이 자리를 다 먹어 종이 위 툴바가 화면의 한 조각을 차지했다.

셸 제목줄의 아이콘 줄과 같은 규범으로 맞춘다 — 이름은 툴팁이 받는다.

<b>글자로 남긴 것은 값과 상태뿐이다.</b> 고른 환자 이름과 배율 —
둘 다 아이콘으로 대신할 수 없는 정보다.

## 버튼 두 개를 하나로 합쳤다

'환자 선택'과 '환자 해제'가 나란히 있었다. 셸 제목줄은 같은 일을 아이콘 하나로 한다 —
안 골랐으면 바로 선택 창, 이미 골랐으면 바꾸기·해제 메뉴다.
늘 보이는 자리에서 '누르면 해제'로 두면 실수로 지운다는 것이 그 설계의 이유였고,
여기도 같은 자리다. 고른 상태는 아이콘 색으로 알린다.

안 고른 상태의 색을 <b>B.Muted 로 명시</b>했다 — null 을 넘기면 하드코딩 검정으로 칠해져
어두운 툴바에서 혼자 묻힌다(제목줄에서 실제로 겪은 결함이라 같은 실수를 반복하지 않는다).

## 아이콘은 실제로 있는 것만 골랐다

카탈로그에 없는 이름을 쓰면 조용히 대체 글리프로 떨어진다 —
쓰려던 refresh-cw · user-x · database · eraser 는 없어서
rotate-cw · table · undo-2 로 바꿨다(90종 중 실재하는 것만 확인해 썼다).

'인쇄될 것만'은 토글이라 눌린 상태가 곧 '인쇄 기준'이라는 뜻이 된다 — 툴팁이 양쪽을 설명한다.

## 값을 치르는 것

글자가 사라져 처음 쓰는 사람은 툴팁을 한 번 훑어야 한다.
이 도구를 며칠에 한 번 쓰는 사용자에게는 실제 비용이다 —
다만 제목줄이 이미 같은 방식이라 규범이 하나로 모이는 쪽이 낫다고 봤다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과
- --dialog-shots 69장 전건 통과 — 미리보기 툴바를 잘라 2배로 키워 다크·라이트 양쪽을 눈으로 확인
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:40:18 +09:00
MsystechandClaude Fable 5 3e75bdc9b9 쿼리 편집기 — 무엇을 돌렸는지, 컬럼이 무엇인지, 늘 뜨는 경고
## 돌린 SQL 을 창 안에서 볼 수 있다

치환을 마친 문장을 필드에 담아 두기만 하고(진단용) 화면에는 안 내보냈다.
0행이 나왔을 때 <b>치환이 NULL 이라 조건이 사라진 것</b>인지
<b>이 환자에게 자료가 없는 것</b>인지 가르는 유일한 수단이 그 문장인데, 볼 방법이 없었다.

요약 줄에 '돌린 SQL 보기' 를 두고 읽기 전용으로 펼친다(SQL 복사도 함께 —
데이터소스 결과 창과 같은 구성이다). <b>실패했으면 자동으로 펼친다</b> —
표가 어차피 없고, 오라클 오류 문구만으로는 어디가 틀렸는지 알 수 없다.

## 0행이어도 컬럼 머리글을 남긴다

전에는 0행이면 표를 통째로 접었다. 그런데 <b>컬럼 이름은 이미 알고 있고</b>,
그것이 배선 필드 칸에 옮겨 적을 값이다. 접어 버리면 방금 조회한 컬럼명을 알아내려고
쿼리를 또 돌려야 한다 — 0행은 배선 작업에서 드문 상태도 아니다.

## 늘 뜨던 경고를 조건부로

'변수 NULL — 구문만 검사' 가 <b>치환 대상이 하나도 없는 쿼리에도</b> 붙었다.
치환할 것이 없으니 NULL 로 바뀐 것도 없는데 거짓 꼬리표가 상시로 달려서,
정작 진짜 경고가 붙은 날에도 눈에 들어오지 않았다. 늘 뜨는 경고는 경고가 아니다.
치환 시도가 실제로 있었을 때만 붙인다.

## 같은 창이 진입 경로에 따라 달랐다

데이터소스 아이콘에서 편집기를 열면 《컨트롤명》 치환 칩이 나오는데
인스펙터의 쿼리 행에서 열면 그 칩이 <b>통째로 사라졌다</b> — 호출부가 컨트롤 이름을 안 넘겼다.
같은 창이 어디서 열렸는지에 따라 능력이 다르면 안 된다. 인스펙터도 넘기게 했다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과
- --query-popup(자동완성 팝업 진단) 전건 통과
- --dialog-shots 69장 전건 통과 — 03b 샷에서 치환 변수 없는 쿼리의 거짓 꼬리표가
  사라진 것을 눈으로 확인했다
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

'돌린 SQL' 펼침과 실패 시 자동 펼침은 DB 가 붙은 단말에서 실제 실패 쿼리로 한 번 확인하는 것이 좋다 —
진단은 성공 경로만 찍는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:27:27 +09:00
MsystechandClaude Fable 5 4b400b94e0 캔버스에서 고른 것을 레이어 목록이 따라오게 한다
1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면 목록에 아무 변화가 없는 경우가 흔했다.
둘 중 하나였다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어 <b>행 자체가 만들어지지 않았거나</b>.

접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다. 그래서 사용자는
"분명히 골랐는데 레이어에는 선택이 없는" 상태를 보고 어느 쪽이 맞는지 판단할 단서를 잃는다.
접기·검색·그룹을 쓸수록 어긋나므로 <b>기존 기능이 오히려 신뢰를 깎고 있었다</b>.

반대 방향(목록 더블클릭 → 캔버스 스크롤·페이지 전환)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
이번 것은 새 기능이 아니라 그 역방향을 잇는 일이다.

## 어떻게

선택이 바뀌면 주 선택의 행을 찾아 스크롤한다. 행이 없으면 조상(페이지 마디·그룹 폴더·컨테이너)을
편다 — 그 결과로 목록이 다시 만들어지고, 재구성 이벤트가 이 경로를 한 번 더 부를 때
행이 있으므로 스크롤만 하고 끝난다. 펴기는 <b>한 번만</b> 시도한다(이미 펴져 있으면 false 를
돌려주므로 재구성이 무한히 돌지 않는다 — 검사로 고정했다).

<b>필터는 건드리지 않는다.</b> 펼 것이 없는데도 행이 없다면 검색·타입 필터에 걸린 것이다.
그건 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다 — 아무것도 하지 않는다.

되먹임은 기존 syncing 가드가 막는다. 목록에서 고르는 경로는 그 가드에 걸려
스크롤이 돌지 않으므로, 클릭하던 자리가 움직이지 않는다.

## 판정

- 단위 시험 388 · edit-smoke 404건 전건 통과 (자동 스크롤 4건 추가)
- --dialog-shots 69장 · --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

검사는 VM 층(조상 펴기·행 생성·재진입 방지)까지다. 실제 스크롤 위치는 ListBox 가상화가
관여하므로 앱에서 긴 서식으로 한 번 확인하는 것이 좋다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:17:06 +09:00
MsystechandClaude Fable 5 6a0331ec8a 레이어 행을 항상 두 줄로 — 둘째 줄에 타입명을 둔다
목록에 1줄 행과 2줄 행이 섞여 있었다. 둘째 줄(이름)이 <b>표시명과 다를 때만</b> 나왔기 때문이다 —
문구가 없는 컨트롤은 표시명이 곧 이름이라 한 줄로 그려졌다.
훑을 때 눈이 매번 자리를 다시 잡아야 하고, 어느 행이 어디서 끝나는지 리듬이 없다.

## 둘째 줄에 무엇을 두는가

<b>타입명</b>이다. 아이콘만으로는 안 갈리는 짝이 실제로 있다 —
라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
그래서 아이콘의 단순 중복이 아니다.

이름(Id)은 <b>더할 정보가 있을 때만</b> 붙인다. 세 갈래로 갈린다.
- 문구가 없다(표시명 = 이름) → 타입명만. "TextBox1 / 텍스트박스"
- 갓 놓았다(문구가 타입명 그대로) → 이름만. 윗줄이 이미 타입을 말했다. "라디오버튼 / RadioButton1"
- 사람이 문구를 썼다 → 둘 다. "환자명 / 라벨 · Label1"

같은 글자가 두 줄에 두 번 나오는 경우가 하나도 없도록 셋을 다 검사로 고정했다.
이름을 계속 보여 주는 이유는 그것이 <b>외부 참조 키</b>이기 때문이다 —
수식(RadioButton1+CheckBox3)·액션 대상 컨트롤·이벤트 매핑이 이름 문자열로 서로를 가리킨다.

## 값을 치르는 것

행 높이가 일정해진 대신 문구 없는 컨트롤이 많은 서식에서 목록이 길어진다.
사용자가 리듬 쪽을 택했고, 타입명이 아이콘 모호성까지 함께 없애므로 값은 한다.

## 판정

- 단위 시험 388 · edit-smoke 400건 전건 통과 (레이어 둘째 줄 5건 추가)
- --dialog-shots 69장 전건 통과 (레이어 4종을 눈으로 대조 — 행 높이 균일 확인)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:10:57 +09:00
MsystechandClaude Fable 5 8c76a978a9 캔버스·인스펙터 잔손질 넷 — 마퀴·치수 칩·죽은 버튼·잘린 설명
## 패널 안에 그린 마퀴가 아무것도 안 잡았다

마퀴가 최상위만 훑고 컨테이너 안으로 안 들어갔다. 패널 안 체크박스 5개를 한 번에 고르려고
안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혀서, Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
컨테이너 자식은 전체 컨트롤의 <b>18.3%</b>(실측 18,749개/컨테이너 4,099개)라 드문 경우가 아니다.

규칙은 <b>'담김'</b>이다 — 마퀴가 컨테이너 사각형 안에 들어가 있으면 그 안의 것을 고른다
(내 상자가 패널보다 작으니 그 안을 고르려는 뜻이다). 걸치기만 하면 예전처럼 패널이 잡힌다.
모드도 수정키도 없이 <b>그린 모양만으로</b> 갈리므로 예측할 수 있다.

한 군데를 더 고쳐야 했다. 클릭 선택이 쓰는 GroupMatesOf 는 자식을 부모 컨테이너로 되돌리는데
(패널 안의 것을 집으면 패널이 움직여야 하므로 옳다) 마퀴가 그것을 그대로 쓰고 있었다 —
안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어 위 규칙이 아무 일도 못 한다.
마퀴 전용 경로를 따로 뒀다. 걸치기 동작이 그대로인 것도 검사로 고정했다.

## 끄는 동안 숫자가 캔버스에 없었다

라벨 폭을 종이 칸에 맞추는 동안 숫자는 300px 떨어진 인스펙터에만 떴다 —
끌고, 손 떼고, 오른쪽 보고, 다시 끈다. 간격 수치 칩은 <b>균등 스냅이 성립할 때만</b> 나타나므로
"지금 이게 몇 px 인가"는 드래그 중에 알 방법이 아예 없었다.

이동이면 좌표, 크기 조절이면 크기를 보여 준다 — 지금 <b>바꾸고 있는 값</b>이 그것이다.
오버레이 층이라 종이에는 없고, 줌이 걸린 층이라 역배율로 글자 크기를 화면 고정으로 만든다.

## 눌러도 아무 일이 없는 ⋮ 버튼

Command·Click·ContextMenu 가 <b>전부 없었다</b>. 그런데 툴팁은 '이 컨트롤에 대한 명령'이라
"단일 선택일 때만 되는 뭔가가 있다"고 오해시켰다. 없는 버튼보다 죽은 버튼이 나쁘다 —
컨트롤 단위 명령은 캔버스 우클릭 메뉴에 이미 다 있다.

## 주석이 논증한 값과 실제 값이 달랐다

라벨 폭 주석이 "92px + 줄바꿈이면 두 줄로 접혀 전부 읽힌다"고 근거까지 적어 놓고
값은 72 였다. 그래서 '액션 대상 컨…' 처럼 잘렸다. 92 로 맞췄다.

같은 자리에서 설명 행(ReadOnlyRow)이 값처럼 잘리고 있었다 —
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
<b>지시에 해당하는 뒷부분</b>이 사라져 무엇을 하라는 건지 알 수 없었다. 접어서 다 보여 준다.

## 판정

- 단위 시험 388 · edit-smoke 396건 전건 통과 (마퀴 2건 · 치수 칩 4건 추가)
- --dialog-shots 69장 전건 통과 (인스펙터 8종을 눈으로 대조 — 라벨·설명 잘림 없음)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:05:30 +09:00
MsystechandClaude Fable 5 f6b456753c 환자 선택 — 첫 걸음이 틀렸다는 것을 화면이 말하게 한다
## 가장 흔한 입력이 조용히 0건이었다

차트번호는 <b>완전일치</b>다. 병원 P_PatInf 는 고정 폭이라 실제 값이 0으로 채워져 있는데
사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데(PatientVisitStore.cs:8)
유일한 호출부가 그 계약을 안 지키고 있었다.

<b>자릿수를 설정으로 정하지 않았다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
대신 0건일 때만 8·10자리로 한 번 더 찾는다(레거시가 쓰던 두 폭이다).
찾았으면 "자릿수를 채워 '0000012345' 로 찾았습니다"를 적고 그 번호를 칸에 되쓴다 —
다음부터 그 번호를 쓸 수 있게. 틀릴 여지가 없는 방식이다.

## 0건일 때 무엇으로 찾았는지 말하지 않았다

유형이 틀려서 0건인 경우가 가장 흔한데(습관대로 이름을 쳤는데 유형은 차트번호)
화면에 힌트가 하나도 없어 접속이나 권한을 의심하게 된다.
"없습니다(차트번호 완전일치로 찾았습니다)"로 유형과 일치 방식을 함께 적는다.

## '기준 시점'이 무슨 뜻인지 알 수 없었다

입원 내원은 <b>구간</b>이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 하는데,
목록에 접수일시만 있어서 그것이 구간이라는 사실 자체가 안 보였다.
이미 조회돼 있으면서 화면에 없던 <b>퇴원일시</b> 열을 붙이고,
선택지를 '마지막(퇴원 시점)'·'처음(입원 시점)'으로 바꿔 무엇을 고르는지 글자로 말한다.

열을 하나 더하면서 상태 열이 화면 밖으로 밀리고 콤보 글자가 잘렸다 —
사진으로 확인하고 일곱 열이 다 들어오도록 폭을 다시 잡았다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과
- --dialog-shots 69장 전건 통과(다크·라이트 눈으로 확인 — 열·콤보 잘림 없음)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 남은 것

검색 유형 자동 판별(입력 모양으로 주민번호·휴대전화·성명·차트번호를 가려내는 것)은
안 넣었다 — 콤보를 사람 몰래 바꾸는 동작이라 "왜 유형이 저절로 바뀌지"를 새로 만든다.
0건 안내가 유형을 밝히게 된 것으로 같은 혼란의 대부분은 없어진다.
자릿수 보정 뒤 재조회는 DB 가 붙은 단말에서 실제 차트번호로 확인이 필요하다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:48:13 +09:00
MsystechandClaude Fable 5 d83478619e 쿼리 편집기 — Enter 가 새던 것과 지나간 검증 결과
## Enter 한 번에 변수를 못 넣은 채 저장되고 닫혔다

치환 변수 검색칸에 KeyDown 이 없었고 확인 버튼은 IsDefault 다.
그래서 '차트' 를 치고 Enter 를 누르면 검색도 삽입도 아니라
<b>SQL 이 저장되고 창이 닫힌다</b>. 변수를 하나도 못 넣은 채 편집이 끝나는데,
무엇이 일어났는지 알기도 어렵다 — 창이 사라졌으니 되짚을 화면이 없다.

Enter 는 지금 걸러진 목록의 선택 항목을 넣는다(검색으로 하나까지 좁혀진 경우가 대부분이다).
↓ 는 목록으로 이동한다 — 손을 마우스로 옮기지 않고 훑을 수 있어야 한다.
둘 다 e.Handled 로 기본 버튼에 닿지 않게 막는다.

## 검증한 뒤 SQL 을 고쳐도 초록 요약이 그대로 남았다

"검증했다"고 믿고 저장하는데 저장되는 것은 <b>검증하지 않은 문장</b>이다.
검증 시점의 원문을 기억해 두고, 지금 텍스트와 다르면 결과 칸을 흐리게 낮추고
요약 앞에 '(수정됨 — 다시 실행하세요)'를 붙인다.

<b>결과를 지우지는 않는다.</b> 방금 본 컬럼명이 사라지면 그것대로 불편하다 —
배선 필드 칸에 옮겨 적는 중일 수 있다. 유효하지 않다는 사실만 말한다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과
- --query-popup(자동완성 팝업 진단) 전건 통과
- --dialog-shots 69장 · --modal-check · --maxrect 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 남은 것

조사에서 함께 나온 것 중 이번에 안 한 것들 — '돌린 SQL 보기'(치환을 마친 문장을 창 안에서 확인),
0행일 때도 컬럼 머리글 남기기, 치환 변수를 안 쓰는 쿼리에 늘 붙는 '구문만 검사' 꼬리표 제거,
인스펙터로 연 편집기에서 컨트롤 변수 칩이 사라지는 것.
전부 근거가 잡혀 있고 다음 회차에 이어서 할 수 있다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:44:53 +09:00
MsystechandClaude Fable 5 e162d0fb6b 태그가 값과 사유를 사실대로 말하게 한다
세 가지가 <b>사실과 다른 말</b>을 하고 있었다.

## ① 로그인 사용자 태그 다섯 갈래가 피커에서만 비어 보였다

피커가 TagPreviewCatalog.FromUser 의 <b>5-인자 위임판</b>을 부르고 있었다.
그 판은 면허·전문의·직종·부서·부서전화를 빈 문자열로 채운다.

    FromUser(tag, id, name, mobile, office)
      → FromUser(tag, id, name, mobile, office, "", "", "", "")

그래서 의사면허번호·전문의번호·직종·부서·부서전화 태그를 피커에서 고르면
"이 계정에는 값이 비어 있습니다"가 뜨는데, 정작 종이에는 실제 값이 찍힌다.
사용자는 그 태그를 못 쓰는 것으로 판단하고 다른 것을 찾게 된다.

이 값들은 로그인 세션(HisUser 15컬럼)이 이미 들고 있다 — 조회를 더하지 않고 본판으로 넘긴다.

## ② 액션 태그 149종에 데이터 태그용 사유가 나갔다

액션 태그는 <b>값이 아니라 동작</b>이다(더블클릭·버튼이 실행한다).
그런데 피커가 데이터 태그와 같은 값 칸을 그려서
"환자 정보가 있어야 값이 나옵니다" 같은 문구가 149종 전부에 붙었다.
오른쪽 설명 칸이 통째로 거짓말을 하고 있었다.

행 종류를 받아 액션이면 "값이 아니라 동작입니다 — 더블클릭·버튼에서 실행됩니다" 한 줄만 둔다.

## ③ 오타 난 태그가 아무 표시 없이 커밋됐다

인라인으로 직접 치는 것이 이 행의 <b>주 경로</b>인데 거기에 검증이 하나도 없었다.
'PAT_이룸' 처럼 한 글자만 틀려도 조용히 저장되고, 그 사실은 미리보기에서 <b>빈칸</b>으로만 드러난다 —
빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 방법이 없다.

글꼴 행과 <b>같은 규칙</b>으로 ⚠ 를 붙인다. 막지는 않는다 —
사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다. 값은 한 글자도 건드리지 않는다.

## 곁가지 — 사유가 접속에 묶여 있었다

DB 가드가 값 계산보다 <b>위</b>에 있어서, 접속이 없으면 '값을 만들 수 없습니다' 사유까지
통째로 감춰졌다. 그 사유는 정적 판정이라 접속과 무관하다 —
정작 가장 필요한 상황(접속 안 된 단말에서 서식을 훑을 때) 안 보였다.
가드를 값 계산 직전으로 내렸다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과 (태그 6건 추가)
- --dialog-shots 69장 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:42:03 +09:00
MsystechandClaude Fable 5 73095c1415 레이어 목록이 계층과 범위를 말하게 한다
사용자가 콕 집어 "레이어 구분이 잘 안 된다"고 했다. 원인은 색이 아니라 <b>산수</b>였다.

## 부모와 자식이 같은 x 에 그려지고 있었다

계층을 나타내는 수단은 들여쓰기 하나뿐인데(평탄한 ListBox 다), 그 폭이
셰브론 폭에 정확히 상쇄되고 있었다.

- 접기 셰브론은 폭 16, 자식이 없으면 Collapsed → <b>폭이 통째로 사라진다</b>
- 컨테이너 자식 들여쓰기는 정확히 16

그래서 패널의 아이콘과 그 <b>직속 자식</b>의 아이콘이 같은 x 에 놓였다.
목록만 보면 부모-자식이 아니라 형제로 읽힌다. 3단 중첩에서는 어느 행이 누구 밑인지
따라갈 수가 없다. 셰브론 자리를 항상 남기도록 고쳤다 — 이제 깊이 한 단계가 16px 사다리로 보인다.

## 접힌 컨테이너가 무엇을 품었는지 말하지 않았다

페이지 행에는 (7), 그룹 폴더에는 (3)이 이미 붙는데 컨테이너만 없었다.
접힌 패널이 3개를 품었는지 300개를 품었는지 펴 보기 전에는 알 수 없었고,
그 패널을 지우거나 옮기기 전에 무엇이 함께 가는지도 알 수 없었다.
중첩까지 세어 붙인다 — 그 수가 곧 "이걸 지우면 몇 개가 같이 지워지나"다.

## 고른 컨테이너의 범위가 안 보였다

캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
그룹을 통째로 고르면 멤버가 네이비 밴드로 묶이는 기구가 <b>이미 있었는데</b>
컨테이너에는 그 갈래가 없었다. 조상 중에 선택된 컨테이너가 있으면 같은 밴드를 준다.
그룹 판정보다 뒤에 둬서 기존 그룹 색을 이기지 않는다.

## 판정

- 단위 시험 388 · edit-smoke 384건 전건 통과 (레이어 4건 추가)
- --dialog-shots 69장 전건 통과 (레이어 샷 4종을 눈으로 대조 — 사다리·개수·밴드 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 아직 남은 것

행 높이가 1줄/2줄로 갈린다 — 표시명이 Id 와 다를 때만 둘째 줄(Id)이 뜨기 때문이다
(ControlViewModel.LayerId). 목록을 훑을 때 리듬이 깨지는데, 고치려면 '항상 두 줄'(공간 낭비)이나
'한 줄에 합치기'(정보 밀도 변화) 중 하나를 골라야 해서 이번 범위에 넣지 않았다.

캔버스에서 고른 것을 목록이 따라 스크롤·펼치는 것(조사에서 medium 으로 나온 항목)도 남았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:43:10 +09:00
MsystechandClaude Fable 5 f8531cb7e1 데이터소스 배선이 실사용에서 전부 죽어 있었다
구조 사용성 조사를 돌렸더니 가장 무거운 것이 사용성이 아니라 <b>버그</b>였다.

## 무슨 일이었나

배선 실행기가 데이터소스를 <b>레거시 클래스명</b>으로 찾고 있었다.

    if (control.Type == "MDataTable")

그런데 서식을 XML 에서 읽는 순간 LegacyTypeCatalog:45 가 그 이름을 중립 이름
"DataTable" 로 바꿔 둔다. 즉 실사용 서식에서는 이 조건이 <b>한 번도 참이 되지 않는다</b> —
데이터소스를 한 개도 못 모은다.

결과는 조용하다. 배선을 옳게 걸어도 모든 DataTableField 가
"데이터소스 X 이 이 서식에 없습니다"로 실패해 미리보기와 <b>인쇄</b>에서 빈칸이 되고,
미리보기의 '데이터소스...' 버튼은 영영 회색이다(Names.Count 가 0이라).
사용자 입장에서는 "배선을 걸었는데 값이 안 나온다" 뿐이고 이유를 알 방법이 없다.

## 왜 여태 못 봤나

진단 네 곳이 전부 ControlElement 를 <b>손으로</b> 만들면서 같은 레거시 이름을 썼다
(EditSmoke:395,739 · DialogShots:465 · DbSmoke:2269). 러너의 오타와 진단의 오타가 짝이 맞아
"배선: 서식에서 데이터소스 이름을 모은다" 가 계속 초록이었다.
<b>로더를 우회해 만든 문서로 로더의 계약을 검사하고 있었다</b> — 검사가 실사용과 다른 세계를 보고 있었다.

## 고친 것

① 러너가 중립 타입명으로 비교한다. 문자열은 LegacyTypeCatalog.DataTableType 상수 하나로 모았다 —
   두 이름이 공존하는 한 각자 적으면 같은 일이 또 난다.
② 진단 네 곳이 같은 상수를 쓰게 했다.
③ 실사용과 <b>같은 경로</b>로 만드는 검사를 새로 넣었다 — 팔레트 경로(AddControlAt("DataTable"))로
   데이터소스를 만들고 러너가 그것을 찾는지, 배선이 '이 서식에 없습니다'로 떨어지지 않는지 본다.

## 고치기 전이었으면 잡혔는지 확인했다

러너 한 줄만 옛 비교로 되돌려 돌려 봤다 — 새 검사 2건과 <b>기존 검사 1건</b>이 실패했다.
기존 검사가 실패한 것은 ②로 진단이 더 이상 오타를 복제하지 않기 때문이다.
이 세 줄이 앞으로 같은 어긋남을 막는다.

## 판정

- 단위 시험 388 · edit-smoke 380건 전건 통과(배선 3건 추가)
- --dialog-shots 69장 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
  (그 게이트는 PageView 직접 렌더라 해석기를 타지 않는다 — 이 수정과 무관하다는 뜻이기도 하다)

DB 가 붙은 단말에서 실제 배선이 값을 내는지는 --db-patient 계열로 별도 확인이 필요하다.
이 커밋이 고친 것은 "데이터소스를 찾는 것"까지다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:37:57 +09:00
MsystechandClaude Fable 5 f4e239075d 창 내용 여백을 16 으로 맞추고, 거짓이 된 주석을 고친다
앞 커밋에서 남겨 둔 것 — 타이틀바는 통일했지만 내용 여백은 창마다 달랐다.
실측하니 12(여섯 창) · 14(데이터소스 배선) · 16(서식 등록)으로 갈려 있었다.
설명 문장이 창 가장자리에 바짝 붙는 창이 여럿이었고, 타이틀바가 생기면서 그 대비로 더 드러났다.

16 으로 맞춘다. 12 는 36px 타이틀바 아래에서 답답하고, 기준으로 삼은 MessageDialogView 는
본문에 20,18,20,14 를 쓴다 — 그 사이에서 큰 창(1180×760 쿼리 편집기)의 내용을
밀어내지 않는 값이 16 이다. 잘림 검사(dialog-shots 69장)가 전건 통과하는 것으로 확인했다.

## 거짓이 된 주석

태그 선택 창에 이렇게 적혀 있었다 —
"창 껍데기는 OS 크롬을 유지한다. … 이 앱의 대화상자 10종이 전부 OS 크롬이라
 이 창만 바꾸면 크롬 규약이 두 벌이 된다."

앞 커밋에서 열한 창을 전부 자체 크롬으로 옮겼으므로 그 전제가 무너졌다.
그대로 두면 다음 사람이 "이 창은 일부러 OS 크롬"이라고 읽고 되돌린다.
무엇이 왜 바뀌었는지까지 적어 둔다 — 주석이 틀렸다는 사실 자체가 정보다.

## 판정

- 단위 시험 388 · edit-smoke 377건 전건 통과
- --dialog-shots 69장 전건 통과(잘림 없음, 다크·라이트 눈으로 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:00:27 +09:00
MsystechandClaude Fable 5 13edf3ecd9 제목줄 환자 아이콘이 혼자 까맸다
지적을 받고서야 실제로 봤다. 앞 커밋에서 "제목줄의 환자 아이콘 버튼은 손대지 않았다 —
옆 아이콘들과 같은 스타일·크기다" 라고 썼는데, <b>마크업만 읽고 화면을 안 봤다.</b>
스타일과 크기는 같았지만 색이 달랐다.

## 무슨 일이었나

이웃 아이콘(복사·삭제·테마·저장)은 XAML 컨버터를 타고, 그 기본 브러시 키가 <b>B.Muted</b> 다.
환자 아이콘만 코드에서 만드는데 안 고른 상태에 <c>null</c> 을 넘겼고,
LucideIcons.Paint 는 키도 색도 없으면 <c>Brushes.Black</c> 으로 칠했다 —
어두운 제목줄에서 <b>안 보이는 아이콘</b>이라는 뜻이다.

컴파일러가 CS8604 로 그 자리(MainView.xaml.cs:139)를 이 세션 내내 경고하고 있었다.
빌드 로그에서 매번 지나쳤다.

## 두 군데를 고친다

① 호출부 — 안 고른 상태의 키를 "B.Muted" 로. 이웃과 같은 토큰이라 테마 전환도 따라간다.
② 기본값 자체 — 색도 키도 없으면 이제 B.Muted 리소스 참조로 떨어진다.
   검정은 다크 테마에서 아이콘을 숨기는 기본값이라 함정으로 남겨 둘 이유가 없다.
   null 을 넘기던 호출부는 ①이 유일했으므로 다른 곳의 색은 바뀌지 않는다.

## 확인 방법도 고쳤다

이번에는 제목줄을 잘라 3배로 키워 다크·라이트 양쪽을 눈으로 대조했다.
전에는 대화상자 사진만 보고 제목줄은 마크업으로 판단했다 — 그래서 놓쳤다.
회귀 방지로 edit-smoke 에 "아이콘 기본색이 하드코딩 검정이 아니다" 를 넣는다.

## 판정

- 단위 시험 388 · edit-smoke 377건 전건 통과 (아이콘 색 1건 추가)
- CS8604 경고 소멸
- --dialog-shots 69장 · --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:56:36 +09:00
MsystechandClaude Fable 5 33461a6c67 환자 선택 창을 확인하다 버튼 두 가지를 고친다
창 자체는 앞 커밋의 공통 크롬을 그대로 받아 타이틀바가 붙었다. 확인하다 두 가지가 걸렸다.

## '찾기(_F)' 가 밑줄 대신 글자 그대로 나왔다

버튼 템플릿의 ContentPresenter 에 RecognizesAccessKey 가 없었다.
그래서 화면에 <b>"찾기(_F)"</b> 가 그대로 찍히고 Alt+F 도 먹지 않았다.
접근키를 쓰는 버튼이 앱 전체에서 이 하나뿐이라 20년째 아무도 안 밟은 자리다.

일반 버튼 템플릿 셋에만 켠다. 태그 제안 칩(SuggestChip)은 자기 템플릿이 따로 있어
걸리지 않는데, <b>거기서 켜면 안 된다</b> — 칩 내용이 데이터 바인딩이라
PAT_이름 같은 태그명이 "PAT이름"으로 잘려 보인다. 버튼 중 데이터를 싣는 것은 그것뿐이다.

## 비활성 강조 버튼의 글자가 배경에 묻혔다

'선택' 버튼은 환자를 고르기 전까지 비활성이다. 앞 커밋에서 이 버튼에 Primary 를 주면서
드러났는데, Primary 의 비활성 처리가 <b>불투명도만 0.45 로</b> 내리는 것이었다.
강조색이 옅어질 뿐 여전히 파랗고, 그 위의 흰 글자는 옅어진 파랑에 묻힌다 —
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"가 겹친다.

비활성은 색을 빼는 쪽으로 바꿨다(중립 채움 + 흐린 글자).
Primary 를 쓰는 모든 창이 같이 얻는다.

## 판정

- 단위 시험 388 · edit-smoke 376건 전건 통과
- --dialog-shots 69장 전건 통과 (다크·라이트 양쪽에서 눈으로 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

제목줄의 환자 아이콘 버튼은 손대지 않았다 — 옆 아이콘들과 같은 스타일·크기(Subtle 34×28)이고
고른 상태를 아이콘 색으로 알리는 것도 그대로다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:50:51 +09:00
MsystechandClaude Fable 5 12e5ec6452 열리는 창들의 결을 통일한다 — 타이틀바와 강조 버튼
## 창 열둘 중 하나만 우리 옷을 입고 있었다

커스텀 크롬을 가진 것은 메인 셸과 MessageDialogView 둘뿐이었고,
나머지 열한 창(서식 이력·데이터소스 배선·글꼴 관리자·태그 선택·쿼리 편집기·마스크·
환자 선택·데이터소스 결과·레이어 필터·서식 등록·미리보기)은 <b>윈도우 기본 타이틀바</b>를 달고 떴다.
다크 테마에서 어두운 앱 위에 OS 바가 얹히고, 높이·글자·모서리·닫기 버튼 모양이 전부 달라
"다른 프로그램이 열렸다"로 보였다.

열한 창이 이미 전부 ThemedWindow 를 걸고 있었으므로, 그 옆에 템플릿을 가진
ThemedDialogWindow 를 만들고 각 창은 <b>스타일 이름 한 줄</b>만 바꿨다.
창마다 타이틀바 markup 을 복사했다면 다음에 또 갈라진다.

## WindowChrome 은 스타일로 줄 수 없었다

두 번 막혔고 둘 다 같은 뿌리다 — WindowChrome 은 Freezable 인데 창에 붙으면서
스스로 속성을 고친다. Setter.Value 에 두면 열한 창이 <b>한 인스턴스를 나눠 쓰다</b>
두 번째 창부터 "읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽었다.
x:Shared="False" 로도 안 됐다 — 공유되는 것은 리소스가 아니라 Setter 가 이미 붙든 값이다.

그래서 ctl:DialogChrome 첨부 속성이 창마다 새 인스턴스를 만들어 붙인다.
템플릿 안 닫기 버튼은 Click 핸들러를 가질 수 없으므로(템플릿에 코드비하인드가 없다)
표준 SystemCommands.CloseWindowCommand 를 쓰고 그 CommandBinding 도 여기서 단다.
DialogResult 를 건드리지 않으므로 ShowDialog 는 false 를 돌려주고 호출부의 '취소' 경로가 그대로 탄다.

최대화 보정도 같이 건다. WindowStyle=None 인 창은 최대화하는 순간 작업표시줄을 덮는데
(메인 창에서 겪은 결함이다) 대화상자 열 개가 크기 조절 가능이라 타이틀바 더블클릭으로
그 상태에 들어갈 수 있다.

## 진단이 바로 그 부분에 눈이 없었다

--dialog-shots 는 window.Content 만 찍고 있었다. 그래서 이 결함을 <b>한 번도 보여 준 적이 없다</b> —
MessageDialogView 만 타이틀바가 사진에 나왔는데, 그건 그 창의 바가 Content 안에 있어서였다.
창 전체를 찍도록 바꿨다(템플릿의 PART_Root). 고칠 대상이 사진에 안 나오는 진단은
있으나 마나였고, 실제로 이번에도 첫 캡처가 "고쳤는데 그대로"로 보여 한 번 헛짚게 만들었다.

## 강조 버튼 — 규칙은 이미 있었다

Primary 스타일을 열한 창 중 넷만 쓰고 있었다. 그런데 IsDefault="True" 는 대부분의 창이
이미 주 동작에 달아 두었다 — 규칙을 새로 만들 필요가 없었다.
<b>기본 버튼 = 강조</b>로 맞춘다(서식 이력·서식 등록·마스크·레이어 필터·데이터소스 결과·환자 선택).

글꼴 관리자만 예외로 손으로 골랐다. IsDefault 가 없고 버튼이 셋인데,
강조는 <b>고른 것만</b> 바꾸는 '선택 조합에 적용'에 준다. '전체에 적용'은 문서 전체를
한 번에 바꾸므로 눈이 먼저 가는 자리에 두지 않았다 — 되돌리기가 있어도
되돌릴 생각을 하려면 먼저 알아채야 한다.

환자 선택의 '선택'에는 Primary 만 주고 IsDefault 는 건드리지 않았다 —
검색칸에서 Enter 는 '찾기'여야 한다.

## 판정

- 단위 시험 388 · edit-smoke 376건 전건 통과
- --dialog-shots 69장 전건 통과(창 전체 캡처로 바뀐 뒤에도)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 남은 것

내용 여백은 아직 창마다 다르다 — 설명 문장이 창 위쪽 가장자리에 바짝 붙는 창이 여럿이다.
타이틀바가 생겨 덜 도드라지지만 통일된 것은 아니다. 별도로 잡을 것.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:42:12 +09:00
MsystechandClaude Fable 5 020595f8d4 사용성 2순위 — 있는 명령에 닿는 길을 낸다
1순위가 '틀린 것 고치기'였다면 여기는 <b>이미 있는 명령에 닿는 길</b>이다.
새 패널·새 화면 없이 커맨드·단축키·메뉴 층에서 끝난다.

## 선택 확장 4종 — 정렬 8종의 실효 사용률을 통째로 끌어올린다

정렬 6종·간격 4종·같은 크기가 다 있는데, 그 명령들이 전제하는 '올바른 선택'을
만들 수단이 없었다. 표 서식에서 가장 흔한 편집이 "이 행의 입력칸 8개를 조금 높이고
아래로 붙이자"인데, 그 8개를 고르는 길이 <b>Ctrl+클릭 8번뿐</b>이다.
마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 온다 —
실측 787건에서 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개·p90 41개다.
즉 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 앞에 붙었다.

주 선택 기준으로 같은 행/열/종류/크기인 <b>형제</b>를 선택에 더한다(Alt+1~4, 우클릭 메뉴 맨 위).
범위를 형제로 한 것은 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있어서다.

2px 오차를 허용한다. 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡히고,
8개 중 5개를 정렬해 놓고 나머지가 남은 것을 못 알아채는 것이 가장 나쁜 결과다.
격자 크기에 묶지 않았다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
잠긴 것은 뺀다. 편집하려고 넓히는 선택인데 안 움직이는 것이 섞이면
정렬을 눌렀을 때 "왜 이건 그대로지"가 된다.

## 줌 단축키 — 커맨드는 처음부터 있었고 키만 없었다

8pt 글자를 확인하려 200% 로 올렸다가 전체 배치를 보려면, 캔버스에서 손을 떼고
<b>화면 맨 아래 가운데</b> 플로팅 바의 배율 버튼을 눌러 팝업을 열고 '화면 맞춤'을 골라야 했다.
확대·축소조차 Ctrl+휠뿐이라 트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.

Ctrl+0(100%) · Ctrl+1(화면 맞춤) · Ctrl+± 를 <b>창</b> InputBindings 에 건다.
캔버스 키 처리기에 두면 안 된다 — 그쪽은 포커스가 TextBox/ComboBox 면 통째로 비켜서므로
인스펙터 값을 고친 직후에 줌 키가 죽는다.

## Ctrl+방향키로 크기 — 위치에만 있던 미세 조정의 짝

입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력했다 —
캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이 화면 6px 이라
작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.

넛지와 같은 400ms 코얼레스를 쓴다. 선은 LineGeometry 를 태워 두께 축이 늘어나지 않게 막는다 —
허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.

## 잠금·숨김을 캔버스에서, 그리고 잠긴 것을 보이게

배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때, 지금은 좌측 패널을 레이어 탭으로
바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 했다. 마퀴로 40개를 잡아 놔도
한 번에 잠글 수단이 없었다. Ctrl+L / Ctrl+Shift+H 와 우클릭 메뉴를 붙인다.

<b>표시가 없던 것이 더 문제였다.</b> 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어,
클릭도 드래그도 안 되는 이유가 화면에 없었다 — "왜 이것만 안 잡히지"만 남는다.
점선 테두리를 <b>오버레이 층</b>에 그린다. 종이 층(PageView)에 넣으면
db-render 게이트가 그것을 찍어 md5 가 깨진다 — 레거시와의 픽셀 동일성을 잠그는 게이트다.

메뉴 이름은 '선택 잠그기'로 한정했다. <b>해제는 캔버스에서 못 한다</b> —
히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다. 해제 경로는 레이어 패널이다.

## Ctrl+\ — 유일한 레이아웃 변경

좌 248 + 스플리터 10 + 우 300 = 558px 가 <b>항상</b> 점유된다. MinWidth 180/250 때문에
스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없다.

좌우를 <b>함께</b> 접는다. 개별 토글 두 개를 두지 않았다 — 목적이 하나라 키도 하나여야 손이 기억한다.
MinWidth 도 함께 0 으로 내려야 실제로 사라진다(Width=0 만으로는 최소폭이 이긴다).
펼친 폭은 따로 기억한다 — 접힌 0 을 사용자가 정한 폭으로 착각하면 다음에 못 되돌린다.
F4(인스펙터 진입)를 누르면 접힌 인스펙터를 먼저 편다.

레이아웃은 이것 말고 바꾸지 않았다. 좌 248 / 우 300 은 이미 측정된 값이고
(레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이
0 스크롤·0 클릭이다. 컨트롤 1,000개짜리 서식에서 트리와 속성을 동시에 봐야 하는 작업이라
떠 있는 패널로 바꾸면 겹침·재배치 비용만 생긴다.

## 판정

- 단위 시험 388
- edit-smoke 376건 전건 통과 (선택 확장 7건 · 크기 조절 2건 · 잠금 5건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 (잠금 표시가 종이에 안 나온다는 증거)
- <b>--cleartype 은 이번에 판정 불가다</b> — 게이트가 스스로 "캡처 실패, 색 픽셀 0" 이라고 보고한다.
  이전 커밋에서 stash 로 되돌려 돌려도 같은 결과라 이 변경 탓이 아니다(환경 문제).
  화면 캡처가 되는 상태에서 다시 확인할 것.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:28:43 +09:00
MsystechandClaude Fable 5 fccd441193 사용성 1순위 — 조용히 틀리게 동작하던 넷을 고친다
사용성 감사를 돌렸더니 결론이 뜻밖이었다. <b>없는 기능이 문제가 아니었다</b> —
정렬 6종·간격 4종·같은 크기·그룹·스냅 가이드·레이어 트리·일괄 편집이 이미 다 있다.
진짜 문제는 있는 것이 틀리게 동작하고, 그 실패가 화면에 아무 흔적을 남기지 않는 것이었다.
넷 다 사용자가 무엇을 잃었는지 모르는 채로 저장하는 경로다.

## ① Ctrl+Shift+Z 가 다시 실행이 아니라 되돌리기였다

CanvasKeyboardBehavior 의 `case Key.Z when ctrl` 에 shift 검사가 없었다.
바로 아래 Key.G 는 ctrl && shift 를 가르는데 Z 만 빠져 있었다.
두 번 되돌린 뒤 한 단계 물리려 Ctrl+Shift+Z 를 누르면 <b>세 단계 전</b>으로 간다.
방향이 반대인 줄 모르면 두세 번 더 눌러 편집분을 통째로 날리는데,
되돌리기는 언제나 성공하므로 경고가 없다. 레이어 패널 쪽도 같은 짝이라 함께 고쳤다.

## ② Ctrl+드래그 복사를 Esc 로 취소하면 사본이 남았다

이번 세션에 Ctrl+드래그 복사를 넣으면서 만든 회귀다. CancelDrag 는 좌표만 되돌리는데,
사본은 이미 문서에 들어가 있으므로 원래 자리로 되돌리면 <b>원본과 픽셀 단위로 겹친다</b>.
화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다 —
증거는 "조금 굵어 보인다" 뿐이다.

복제한 드래그였으면 좌표 복원 대신 복제 자체를 물린다. 순서가 중요하다:
Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 죽은 참조가 된다 —
상태를 먼저 비우고 그다음에 되돌린다. 그리고 DiscardRedo 로 redo 이력도 버린다.
Esc 는 "되돌린다"가 아니라 "없던 일로 한다"이므로, Ctrl+Y 로 되살아나면 취소가 아니다.

## ③ 우클릭이 커서 아래를 잡지 않았다

우클릭 메뉴는 20항목인데 우클릭 <b>선택</b> 핸들러가 저장소 전역에 0건이었다.
라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 우클릭하면 메뉴는 B 위에 뜨는데
[삭제]는 A 를 지운다. 선택이 비어 있으면 항목이 전부 살아 있는 채로 아무 일도 안 한다.
윈도우의 거의 모든 프로그램이 지키는 규약이라 사용자는 메뉴가 가리키는 대상을
확인할 생각조차 하지 않는다 — 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.

이미 선택 안에 있으면 선택을 유지한다(다중 선택 우클릭 정렬 흐름을 깨면 안 된다).
e.Handled 는 두지 않아 기존 ContextMenu 가 그대로 열린다.

<b>함정 하나</b>: HitTestControl 은 잠금·숨김을 히트 후보에서 아예 빼므로,
잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 선택이 사라진다.
잠금은 편집 보호지 부재가 아니다 — AnyControlAt 을 따로 만들어 그 위에서는 선택을 지우지 않는다.

## ④ 저장 안 된 문서를 화면에서 구별할 수 없었다

UndoService.IsDirty 는 저장 지점 깊이까지 추적해 정교하게 있었는데
(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤, 어느 것이 안 저장됐는지 알 방법이 없다 —
탭 제목이 저장 전후로 글자 하나 다르지 않다. 그래서 안전하게 전부 다시 저장하게 되는데
<b>DB 저장은 새 버전을 만든다</b> — 서식마다 쓸데없는 버전이 쌓인다.

탭에 6px 점을 찍는다. 통지는 값이 <b>바뀔 때만</b> 울린다 — 매번 울리면 드래그 한 번에 수백 번이다.

## 판정

- 단위 시험 388
- edit-smoke 362건 전건 통과 (Esc 취소 4건 · 되돌리기/저장표시 6건 · 잠김 히트 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --dialog-shots · --cleartype 전건 통과

## 감사에서 나온 나머지

2순위(선택 확장 4종·줌 단축키·Ctrl+방향키 크기·잠금 토글)와 3순위는 아직 안 했다.
<b>레이아웃은 바꾸지 않기로 했다</b> — 좌 248/우 300 은 이미 측정된 값이고
(레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이
0 스크롤·0 클릭이다. 다만 패널을 접을 수단이 없는 것은 실재하는 부재다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:16:15 +09:00
MsystechandClaude Fable 5 be9d697a0d 글꼴을 목록에서 고른다 — 매번 타이핑하지 않는다
글꼴 칸이 자유 입력 텍스트 하나였다. "돋움"을 매번 손으로 쳐야 했고,
한 글자만 틀려도 레거시는 <b>아무 말 없이</b> 기본 글꼴로 떨어진다 —
디자이너에서는 멀쩡해 보이고 EMR 에서만 다른 글꼴로 나온다.

이제 설치된 글꼴 목록에서 고른다. 목록의 각 이름은 그 글꼴로 그린다 —
이름만으로는 어떤 모양인지 알 수 없다. 페이지 기본 글꼴 칸도 같은 것을 쓴다
(전에는 페이지 쪽만 자유 입력으로 남아 있었다).

## 이름의 형태가 핵심이다

레거시가 저장하는 값은 GDI 가 주는 <b>한글 이름</b>이다 — 실사용 서식 실측:
"굴림, 11.25pt, style=Bold" · "돋움, 9.75pt". 그런데 WPF 의 FontFamily.Source 는
같은 글꼴을 "Gulim" 으로 준다. 영문 이름을 목록에 쓰면 <b>고르는 순간</b> 서식의 글꼴
이름이 바뀌고, 레거시가 그 이름을 못 찾으면 조용히 기본 글꼴이 된다.

그래서 글꼴이 스스로 담고 있는 ko-KR 이름표를 먼저 읽고, 없을 때만 원래 이름을 쓴다.
검사가 이것을 고정한다 — 목록에 "굴림"·"돋움"·"맑은 고딕" 중 하나는 반드시 있어야 한다.

## 목록에 가두지 않는다

병원 PC 와 이 PC 의 설치 글꼴은 다르다. 목록에만 고를 수 있게 하면
여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없고, 칸을 건드리는 순간 사라진다.
그건 서식이 틀린 것이 아니라 <b>지금 이 화면이 병원과 다르게 보인다</b>는 뜻일 뿐이다.

그래서 고르기도 되고 쓰기도 되는 칸으로 두고, 이 PC 에 없는 글꼴이면
값은 그대로 둔 채 ⚠ 로 알린다("저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다").

커밋은 포커스를 잃을 때 한 번이다 — 커밋 한 번이 문서 전체 딥클론 1회라
글자마다 커밋하면 "맑은 고딕" 여섯 자에 스냅샷 여섯 개가 쌓인다.

## 판정

- 단위 시험 388
- edit-smoke 348건 전건 통과 (글꼴 9건 추가 — 한글 이름·저장 형식·없는 글꼴 보존·경고)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --dialog-shots · --cleartype 전건 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 16:30:49 +09:00
MsystechandClaude Fable 5 d45b674e29 여러 개를 골라 공통 속성을 한 번에 바꾼다
커밋 규약은 그대로다 — BindRow 는 이미 다중 대상이었고(스냅샷 1회 → 선택 전체 순회)
바뀐 것은 "어떤 행을 만드는가"뿐이다. 새 커밋 경로를 만들지 않았다.

## 막혀 있던 곳

타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다(RebuildCore 의 items.All(i => i.Type == type)).
그런데 여러 개를 함께 고쳐야 하는 속성 — 읽기 전용·인쇄 출력·필수 입력·재조회 — 이 정확히
거기 있었고, 없는 이유를 알려 주는 표시도 없어 "이 도구로는 안 되는 일"로 보였다.

더 나쁜 것은 <b>탭</b>이었다. 탭 가시성을 행을 만들기 전에 descriptor 하나로 정하는 바람에
타입이 섞이면 데이터·동작 탭이 접히고 selectedTab 이 디자인으로 강제 복귀했다.
공통 섹션을 완벽하게 만들어도 그 뒤에 갇힌다 — 순서를 바꾸지 않으면 어떤 병합 규칙으로도 못 고친다.
그래서 '① 공통 속성 계산 → ② 탭 → ③ 섹션' 으로 재배치했다.

## 무엇을 공통으로 보는가 — BatchEditPolicy

키 이름이 같은 것으로는 부족하다. Editor·Choices(원소와 순서)·Default 까지 같아야 한 칸에 묶는다.
이 규칙 하나가 예외 코드 없이 함정들을 잡는다.

- 정렬(TextAlign): 라벨은 ContentAlignment 9값, 텍스트박스는 HorizontalAlignment 3값이다.
  이름만 보고 묶으면 "MiddleCenter" 가 텍스트박스에 들어가고 레거시는 그것을 읽지 못한다.
- 테두리(BorderStyle): 기본값이 Fixed3D 대 None 이다. 기본값이 갈리면 키가 없는 대상이
  <b>남의 기본값</b>으로 보이고, 갈렸는지 판정부터 틀어진다.
- 표시(visible/Visible): 라벨만 소문자다(레거시가 그림자 속성을 쓴다). 키가 달라 자연히 탈락한다.
  <b>별칭으로 묶지 않았다</b> — 묶으면 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데
  우리 미리보기는 계속 보여 준다.
- 스키마를 모르는 타입(표 등)이 섞이면 빈 집합이다. 무엇이 공통인지 말할 근거가 없다.

그 위에 대상 집합을 보는 차단을 건다. 근거 대부분이 키 이름이 아니라 <b>누가 선택됐는가</b>에 있다.

- 유일해야 하는 것: 이름·서명 슬롯·탭 순서·링크 대상. 서명 슬롯이 같으면 전자동의서가
  보호자 칸에 환자 서명을 찍는다 — 종이에는 서명이 다 있어 아무도 의심하지 않는다.
- 남을 이름으로 가리키는 배선: 액션 대상 컨트롤·합산식·이벤트 매핑. 같은 값을 넣으면
  5번 문항을 누를 때 1번 문항이 비워지는 식으로 <b>다른 곳</b>이 깨진다.
- 되돌릴 수 없는 것: 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
  스냅샷으로 덮이지 않는 유일한 부류라 커밋 자체를 막는다.
- 같은 부모의 라디오 Checked: 레거시 라디오는 GroupName 이 없어 직계 부모가 문항인데,
  로더가 자식을 부모에 붙이기 <b>전에</b> 값을 대입해 형제가 꺼지지 않는다.
  한 문항에 답이 둘 켜진 채로 저장되고 그대로 열린다. 부모를 모르면 막는 쪽으로 뒀다.
- 선의 방향·굵기: 전용 편집기는 컨트롤마다 크기까지 함께 고치므로 허용하고,
  고급(원문) 경로만 막는다 — 거기서는 속성만 바뀌어 XML 은 Vertical 인데 경계는 가로인 선이 된다.

이름 행은 items.Count == 1 을 <b>유지</b>했다. 푸는 것이 목표처럼 보였지만 반대다 —
풀면 두 번째 대상부터 이름이 충돌하는데, 그 거절이 모달을 띄우고도 커밋 루프를 멈추지 못한다.

## 고급(원문) 섹션을 다중 선택에도 열되, 판정을 대상 전체로

전에는 행의 종류·편집 여부·키 목록을 items[0] 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다.
그래서 이미지·중첩·참조 값을 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고,
클릭 순서에 따라 같은 조작이 안전해지거나 파괴적이 됐다.
게다가 GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지 원문이 둘 다 "" 로 접혀
<b>갈렸다는 표시조차 나오지 않았다</b> — 경고 없이 base64 가 문자열로 교체된다.

이제 전부 갖고 전부 문자열일 때만 편집을 연다. 아니면 이유를 밝힌 읽기 전용 행이다
("3개 중 1개에만 있음", "대상마다 값의 형태가 다릅니다"). 없던 대상에 키를 만들면
레거시 서식생성기가 열 때마다 오류 모달을 띄우므로 만들지 않는다.
'속성 추가'는 단일 선택만 — 중복 검사·쓰기가 전부 대상 하나 기준이라 N개 중 1개에만 키가 생긴다.

## 함께 고친 것 — 다중 편집을 넓히기 전에 막아야 했던 것들

**갈린 토글을 한 번 누르면 꺼졌다.** 실측으로 확인했다(진짜 ToggleButton + 진짜 바인딩):
누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. 즉 굵게를 <b>켜려고</b> 누른 한 번이
선택 전체의 굵게를 껐다. WPF 는 불확정에서 IsThreeState 와 무관하게 false 로 간다.
게다가 세그먼트 토글에는 갈림 표시가 없어 '꺼짐'과 픽셀 단위로 같았다 — 무엇이 일어났는지
볼 수도 없었다. 갈린 상태의 클릭을 '켜기'로 읽고, {x:Null} 표시를 붙였다.

**갈린 값이 '없음'으로 보였다.** 쿼리·마스크·배선 행은 빈 칸이 아니라 "(쿼리 없음)" 같은
<b>단정</b>을 그린다. 서로 다른 쿼리를 든 둘을 골랐을 때 "쿼리 없음"이 나오면 빈 칸보다 나쁘다 —
없다고 믿고 새로 쓰면 양쪽 원본이 한꺼번에 사라진다. "(여러 값)" 으로 바꿨다.
정렬 격자도 갈리면 9칸이 전부 꺼져 '아직 안 고름'과 같았다 — 격자 오른쪽에 표식을 뒀다.

**안 바뀌었는데 문서가 '수정됨'이 됐다.** 갈린 숫자 칸에 "100px" 을 넣으면 스냅샷이 먼저
쌓이고 대상마다 파싱에 실패해 아무것도 안 바뀌었다. 그걸 지우려 누르는 Ctrl+Z 가 다음 문제를 밟는다.
RowBinding.Validate 를 두어 커밋 전에 거르고 칸을 되돌린다. 값이 이미 전부 같으면 스냅샷도 안 찍는다.

**Undo 한 번에 선택이 증발했다.** 문서 교체 후 재구성의 첫 줄이 Selection.Clear() 라,
'여럿 고르기 → 바꾸기 → 확인 → Ctrl+Z → 다시'라는 이 기능의 유일한 작업 흐름이
첫 되돌리기에서 끊겼다. Id 로 다시 찾아 선택을 복원한다.

**텍스트·항목 목록은 확인을 받는다.** 대상마다 다르던 고유값이 한 번에 사라지는 편집이다.
좌표·색·인쇄여부에는 붙이지 않았다 — 확인을 남발하면 정작 위험한 것도 습관적으로 넘긴다.

## 재현하지 못한 것

조사에서 P0로 지목된 ComboBox 코어스 되쓰기(목록에 없는 값을 null 로 바꿔 소스에 되써서
세로선을 회전시킨다)는 <b>재현되지 않았다</b>. 목록 밖 값을 넣은 양성 대조에서도 되쓰기가
관측되지 않아, 이 진단은 그 경로에 둔감하다. 그래서 '되쓰기가 없다'를 주장하지 않는다.
대신 갈렸을 때 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할
조건 자체를 없앴다 — 덤으로 빈 칸이 "여러 값"으로 보인다.

## 판정

- 단위 시험 388 (BatchEditPolicy 20건 추가)
- edit-smoke 339건 전건 통과 (일괄 편집 12건 · 혼합 표시 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" · --dialog-shots · --cleartype · --db-filecfg 전건 통과

## 남는 것

- 혼합 상태에서 '선택 전체를 빈 값으로' 는 못 한다 — ""→"" 가 커밋되지 않아서다.
  하려면 커밋을 우회하는 새 경로가 필요하고 그건 별건이다.
- 갈린 숫자 칸의 ↑/↓ 는 무동작이다(빈칸을 0으로 뭉개지 않으려는 기존 방어).
  다중 선택은 좌표가 갈리는 것이 기본에 가까워 자주 보이지만 이번 범위 밖으로 뒀다.
- 라벨의 대문자 Visible 을 PrintFilter 가 레거시와 다르게 읽는 문제는 <b>지금도</b> 있다.
  이번 설계는 병합에서 라벨을 빼 새 사례를 만들지 않을 뿐, 기존 어긋남은 고치지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 16:24:16 +09:00
MsystechandClaude Fable 5 8cc2d68ac1 미리보기를 눌러 볼 수 있게 하고, Ctrl+드래그를 복사로 바꾼다
사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.

## 미리보기 클릭이 안 먹었다

레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 눌러 보며 확인한다.
우리 미리보기는 그림이었다 — 그래서 <b>라디오가 배타로 묶였는지 확인할 방법이 없었다</b>.
패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이
이 서식에서 가장 흔한 사고인데, 저장 전에 그걸 볼 수단이 없었던 것이다.

PreviewCheckState — 컨트롤 Id → 체크 상태 사전. 문서는 건드리지 않는다.
확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은 것만으로
저장할 내용이 생긴다. 렌더 시점(WithTagValue)에만 덮고, 창을 닫으면 사라진다.

배타는 직계 부모 안에서만. 레거시 RadioButton 은 WinForms 를 그대로 상속해
GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식 — 패널 1개 = 문항 1개다.

- PreventEditing=True 는 안 눌린다(RadioButton.vb:271-279 — 값 대입만 되고 클릭은 무시).
  Locked 는 편집 보호일 뿐이라 눌린다.
- 라디오는 다시 눌러도 안 꺼진다. 끄는 것은 형제를 누르는 것뿐이다.
- 손으로 누른 값이 태그 값을 이긴다 — 그 칸을 직접 눌러 보는 중이므로 사용자의 손이 마지막이다.
- 환자를 바꾸거나 서식을 갈아타면 지운다. 안 지우면 손으로 누른 값이
  새 환자의 실제 값을 가려, 확인하려고 만든 창이 확인을 막는다.
- 이 창에서 인쇄하면 화면과 같은 상태로 나간다(같은 checks 를 Print 까지 넘긴다).

히트테스트는 WPF 가 아니라 모델 트리로 한다. 종이 비주얼은 템플릿이 만든 그림이라
컨트롤 하나가 여러 요소로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
캔버스의 HitTestControl 과 두 군데만 다르다 — Locked 를 보지 않고,
그려지지 않은 것(숨김·데이터소스·인쇄 제외)을 뺀다.

## Ctrl+드래그가 복사가 아니었다

예전에는 '선택에 더하고 이동'이었다(Ctrl 이 선택 추가 수정자였으니까).
그래서 같은 컨트롤을 여러 개 놓는 방법이 복사·붙여넣기뿐이었는데,
붙여넣기는 <b>항상 최상위 +12,+12</b> 에 떨어진다.
패널 안 라디오를 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다 —
눈으로는 안 보이고 임상 화면에서만 드러나는 고장이다.

DuplicateSelectionInPlace — 제자리 복제. 부모를 유지하므로 사본이 같은 문항 안에 남는다.
드래그 문턱을 넘은 지점에서 복제한다(누름 시점에 하면 Ctrl+클릭만으로도 사본이 생긴다).
복제가 스냅샷을 이미 찍었으므로 이어지는 이동은 추가로 찍지 않는다 —
안 그러면 "복사 후 이동"이 Undo 두 번이 되고, 한 번만 누른 사용자는
원본 자리에 사본이 겹쳐 남은 상태를 본다.

Ctrl 은 이 제스처에서 더하기가 아니다. 합집합으로 두면 아까 선택해 둔 컨트롤까지
함께 복제돼 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. Shift 는 그대로 더하기다.

선택 단위가 패널이므로 자식을 그냥 집으면 <b>문항이 통째로</b> 복사되고,
더블클릭으로 들어간 뒤 집으면 <b>그 선택지만</b> 복사돼 같은 패널에 남는다.
둘 다 필요한 동작이라 둘 다 시험한다.

## 판정

- 단위 시험 368 (PreviewCheckState 10건 추가)
- edit-smoke 320건 전건 통과 (Ctrl+드래그 복사 9건, 미리보기 클릭 5건 추가)
  · 20-5f 의 옛 단언 2건은 의미가 바뀌어 교체했다("합집합으로 함께 움직인다" → "복사된다")
  · 20-5c(빈 선택 Ctrl+드래그)가 남기는 사본을 되돌린다 — 안 하면 뒤따르는 개수 검사가 흔들린다
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" 39건 · --dialog-shots · --cleartype · --db-filecfg 전건 통과

시험을 처음 쓸 때 자식을 Children[0]/[1] 로 집었는데 자식 VM 은 z 순서로 온다.
이미 켜진 라디오를 누르는 시험이 되어 "문서가 바뀌었다"는 거짓 실패가 났다 — Id 로 집는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:07:51 +09:00
MsystechandClaude Fable 5 d965b3f217 라디오버튼도 패널로 묶는다 — 차단을 경고로 (운영 표준 패턴을 막고 있었다)
조사 워크플로(읽기 3 + 반박 검증 3)로 확정한 사실:
- 레거시 RadioButton 은 System.Windows.Forms.RadioButton 을 그대로 상속하고
  GroupName 류 속성이 소스·IL 어디에도 없다 → <b>직계 부모가 곧 배타 단위</b>
  (검증자가 실제 어셈블리를 로드해 Panel/GroupBox/MExpandablePanel/호스트가
   각각 독립 집합이고, 패널 밖으로 옮기면 배타가 즉시 끊김을 실행으로 확인)
- 운영 전수(디자인 1,270건 전량 덤프): 라디오 15,184개 중 <b>15,026개(98.96%)가
  패널 직계 자식</b>, 그룹 5,582개(평균 2.72 · 중앙값 2 · 최대 72),
  최상위 직속은 158개(1.04%)뿐. 그룹을 나타내는 속성은 0건 —
  즉 "패널 1개 = 문항 1개" 가 사실상의 저장 규약이다

그런데 우리는 라디오 Ctrl+G 를 <b>아예 막아</b> 두었다(GroupBlockReason ①) —
운영 서식의 98.96% 패턴을 우리 도구로는 만들 수 없었다는 뜻이다.
막은 이유("일부만 묶으면 택1이 갈라진다")는 타당하지만 그것은 경고할 일이지
기능을 없앨 일이 아니다. 오히려 막으면 한 페이지의 라디오가 전부 한 그룹이 되어
문항을 여러 개 만들 수 없다.

- 차단 제거, 대신 같은 자리에 <b>묶이지 않고 남는 라디오가 있을 때만</b>
  "N개 중 M개만 묶습니다 — 남는 것은 다른 선택 묶음이 됩니다" 로 되묻는다
  (RadioSplitWarning). 전부 묶으면 되묻지 않는다.
- edit-smoke: "라디오는 묶이지 않는다" 판정을 뒤집어 "라디오도 패널로 묶인다"
  + "패널 자식이 된다" + 경고 판정 2건(일부만/전부)으로 교체
- GroupSelection 의 낡은 XML 주석("동일 GroupId 부여")을 실제 동작(패널 래핑)과
  근거 수치로 교체 — GroupId 계열은 이미 죽은 코드다

- dotnet test 358/358 · --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 14:44:20 +09:00
MsystechandClaude Fable 5 345b2e1da1 싸인 이미지 SMB 직접 읽기 — 공유 매핑 구현(서버측 SMB 개방 대기)
사용자 결정(②SMB 직접 접근)에 따라 구현. 실측 확인:
- 파일서버 = DB 서버와 같은 호스트, 전송 데몬 포트 2002 는 열림
- <b>SMB 445·139 는 닫힘</b>, UNC·관리공유 접근 모두 실패 → 서버측 조치 필요
- 서명 경로는 서버측 절대경로 D:\MsystechHIS\{사이트}\... (설정 루트 E:\msys\ 와 다름)

그래서 공유가 열리는 즉시 동작하도록 매핑을 넣었다:
Images:SignatureShareMap 에 "서버접두=UNC접두" 를 두면 해석 순서가
①레거시 캐시 ②공유(UNC) ③직접 경로가 된다. 접두는 대소문자 무시로
<b>긴 것부터</b> 맞추고(짧은 접두가 정확한 대응을 묻지 않게), '=' 없는
설정 오타는 무시한다(반쪽 경로를 만들면 조용히 틀린다). 자격증명은
설정에 담지 않는다 — 공유 접근은 단말 로그인 계정으로 붙는다.

진단 --db-filecfg 확장: 접속 <b>대상</b>(Type·IP·Port·FileDirectory)과
경로 접두 분포를 찍고(자격증명은 길이만), 공유 매핑별 접근 가능 여부와
파일서버 SMB(445) 개방 여부를 판정해 무엇을 열어야 하는지 알려 준다.

- dotnet test 358/358 (매핑 4건 추가: 치환·긴 접두 우선·오타 무시·해석 순서)
- --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 12:40:56 +09:00
MsystechandClaude Fable 5 a0333791b6 싸인·직인·로고 17종 — 경로 조회와 이미지 렌더 이식(전송 계층은 재현 불가)
워크플로 조사(읽기 5 + 반박 검증 5)로 전송 계층과 태그 17종을 훑었고,
검증이 조사서의 "FTP" 단정을 반박해 실체가 드러났다: 전송 수단은 단일
프로토콜이 아니라 DB 설정(M_DtlMst 'EMR_FileTransfer' Type)이 고르는
5갈래이고, 새 진단 --db-filecfg 로 확인한 우리 사이트 값은 Type=1 =
FileServer = ActiveX OCX(AxBITSFTLib.AxbitSFT)다. OCX 내부 프로토콜은
코드에 없고 서드파티·ActiveX 는 배포 금지라 <b>다운로드는 재현할 수 없다</b>.

그래서 받지 않고 찾는다: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
({설치경로}\Tmp\DownLoadedFiles\Signature\ — 레거시가 쓰는 그 폴더),
②DB 경로 자체가 열리는 경우(이 병원 저장 형태는 드라이브 절대경로).
둘 다 없으면 사유 → 종이는 빈칸(어제 결정). 판정은 순수 함수
SignatureImagePath 로 두고 테스트 7건(캐시 우선·직접 경로·둘 다 없음·
빈 경로·설치경로 없음·UNC).

경로 조회는 전부 순수 SQL 이라 그대로 이식(SignatureStore): 집도의
(DTRUIDCOD)·마취(수술 ANEUIDCOD / 부서 — 개원일 2021-03-01 분기)·
영상의학(XRAY)·진단검사(LAB)·외출외박 승인자(병동/원무)·병동 수간호사·
병원로고(M_HspMst.HspLgoPth)·병원직인(주석과 달리 DB_REGISTRY 의
'직인경로'). 로그인 사용자 3종은 세션 행에 UidImgPth 를 얹어 왕복 0,
담당의·진단서 의사는 프리페치 행 재사용.

레거시 결함 보존 2건: ETC_병리판독의사싸인은 SELECT 에 UidNam 만 있고
UidImgPth 를 읽어 항상 예외→빈 값(:17434), OCM_Cosign_싸인은 저장 기록
키(EmrKey) 의존이라 미리보기에서 값이 될 수 없다 — 둘 다 사유로 완결.

렌더: PictureBox 가 자리표시자만 그리던 것을 실제 이미지로. 파일을
잠그지 않고(OnLoad) 읽으며, 인쇄가 SizeMode 를 무시하고 사각형에 늘려
그리는 레거시 동작에 맞춰 기본 Stretch=Fill(Zoom 만 비율 유지).
WithTagValue 는 이미지류에서 Text 대신 경로 속성을 채운다.
분류도 정정 — 싸인 계열이 Image(글자만 보여 준다)에서 PatientContext 로.

- dotnet test 354/354 (경로 판정 7건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊲ 전건 통과 — ㊲ 신설: 9개 경로 조회 실행,
  마취과 의사 싸인 경로가 실값 29자로 나온다
- --db-filecfg 신설(값 없이 모드만): Type=1 · E_FilInf 102만행 ·
  UidImgPth 157행 · 경로 모양 드라이브 절대경로
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:18:35 +09:00
65 changed files with 5426 additions and 288 deletions
+327
View File
@@ -0,0 +1,327 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 여러 컨트롤을 함께 골랐을 때 <b>무엇을 한 번에 바꿔도 되는가</b> — 판정 한 곳.
///
/// <b>왜 순수 함수 한 곳인가.</b> 차단 근거의 대부분이 키 이름이 아니라 <b>대상 집합</b>에 달려 있다
/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가).
/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고,
/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고,
/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다.
///
/// <b>기준은 레거시가 열 수 있는 XML 이다.</b> 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을
/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라
/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다.
/// </summary>
public static class BatchEditPolicy
{
#region Member Fields
/// <summary>
/// 대상이 둘 이상이면 <b>언제나</b> 막는 키 — 값이 대상마다 달라야 의미가 있는 것들.
///
/// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다.
/// ① <b>유일해야 하는 식별자</b> — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상.
/// ② <b>남을 이름으로 가리키는 배선</b> — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면
/// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 <b>다른 곳</b>이 깨진다.
/// ③ <b>되돌릴 수 없는 것</b> — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
/// Undo 스냅샷으로 덮이지 않는 유일한 부류다.
/// </summary>
private static readonly HashSet<string> AlwaysBlocked = new(StringComparer.Ordinal)
{
// ① 유일해야 하는 것
"Name",
"SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다
"DisplaySequence",
"TabIndex",
"LinkToNewDesign",
"SMSWebControlName",
// 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다
"Location",
"Size",
"LocationOnBase",
// ② 남을 이름으로 가리키는 배선
"DataActionTagControl",
"GetDataActionTagControl",
"GetDataActionTagControlChange",
"AssociatedControl",
"Formula",
"FormulaResultCheck",
"EventHandlerMappingTag",
// ③ 되돌릴 수 없는 것 — 일련번호 발급
"SequenceCode",
"SequenceType",
"SequenceNumber",
"CheckSequenceAtSaving",
};
/// <summary>
/// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다.
///
/// <c>TextAlign</c> 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은
/// <c>ContentAlignment</c>(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는
/// <c>HorizontalAlignment</c>(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면
/// 텍스트박스 쪽에는 <b>레거시가 읽을 수 없는 값</b>이 들어간다.
/// </summary>
private static readonly HashSet<string> BlockedWhenTypesDiffer = new(StringComparer.Ordinal)
{
"TextAlign",
// 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) —
// 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다
"DataInterfaceTag",
};
/// <summary>
/// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 <b>속성과 크기를 함께</b> 고치므로 안전하다.
///
/// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 <c>LineGeometry</c> 를 태워
/// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어,
/// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다.
/// </summary>
private static readonly HashSet<string> BlockedInRawOnly = new(StringComparer.Ordinal)
{
"Orientation",
"BorderWidth",
};
/// <summary>
/// 타입마다 라벨이 갈리는 키의 <b>중립 라벨</b> — 섞였을 때 한쪽 타입의 말을 쓰지 않는다.
/// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다.
/// </summary>
private static readonly Dictionary<string, string> NeutralLabels = new(StringComparer.Ordinal)
{
["Checked"] = "기본값",
["Text"] = "텍스트",
["Items"] = "항목(줄바꿈 구분)",
};
#endregion
#region Types
/// <summary>일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유</summary>
public readonly record struct BatchVerdict(bool Allowed, string? Reason)
{
/// <summary>허용</summary>
public static BatchVerdict Ok => new(true, null);
/// <summary>차단 — 이유는 인스펙터에 그대로 보인다</summary>
public static BatchVerdict No(string reason) => new(false, reason);
}
#endregion
#region Methods
/// <summary>
/// 선택된 타입들이 <b>모두</b> 갖는 속성 — 이것이 "공통 속성"이다.
///
/// <b>키 이름만 같으면 안 된다.</b> 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다.
/// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다.
/// · <c>TextAlign</c> — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다).
/// · <c>BorderStyle</c> — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면
/// 키가 없는 대상이 <b>남의 기본값</b>으로 보이고, 갈렸는지 여부 판정부터 틀어진다.
/// · <c>visible</c>/<c>Visible</c> — 라벨만 소문자다(레거시가 그림자 속성을 쓴다).
/// 키 문자열이 다르므로 자연히 탈락한다. <b>별칭으로 묶지 않는다</b> — 묶으면 라벨에
/// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
///
/// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 <b>빈 집합</b>이다.
/// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다.
/// </summary>
public static IReadOnlyList<PropertyDef> MergeCommonDefs(IReadOnlyList<string> types)
{
if (types.Count == 0)
{
return Array.Empty<PropertyDef>();
}
var descriptors = new List<ControlDescriptor>(types.Count);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
return Array.Empty<PropertyDef>();
}
descriptors.Add(descriptor);
}
if (descriptors.Count == 1)
{
return descriptors[0].Properties;
}
var merged = new List<PropertyDef>();
// 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다
foreach (var def in descriptors[0].Properties)
{
var common = true;
var labelDiffers = false;
for (var i = 1; i < descriptors.Count && common; i++)
{
var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal));
if (other is null || other.Editor != def.Editor
|| !SameChoices(def.Choices, other.Choices)
|| !string.Equals(def.Default, other.Default, StringComparison.Ordinal))
{
common = false;
break;
}
labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal);
}
if (!common)
{
continue;
}
merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral)
? Relabel(def, neutral)
: def);
}
return merged;
}
/// <summary>
/// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다.
/// 조용히 빼면 사용자는 그 속성이 <b>존재하지 않는다</b>고 읽는다.
/// </summary>
public static int ExcludedCount(IReadOnlyList<string> types)
{
if (types.Count < 2)
{
return 0;
}
var all = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
continue;
}
foreach (var def in descriptor.Properties)
{
all.Add(def.Key);
}
}
foreach (var def in MergeCommonDefs(types))
{
all.Remove(def.Key);
}
return all.Count;
}
/// <summary>
/// 이 키를 <b>이 대상들에</b> 한 번에 써도 되는가.
/// </summary>
/// <param name="key">PropBag 키</param>
/// <param name="targets">선택된 컨트롤 모델</param>
/// <param name="viaAdvancedRaw">고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다</param>
/// <param name="parentOf">컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다.</param>
public static BatchVerdict IsBatchable(string key, IReadOnlyList<ControlElement> targets,
bool viaAdvancedRaw = false, Func<ControlElement, ControlElement?>? parentOf = null)
{
// 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다
if (targets.Count <= 1)
{
return BatchVerdict.Ok;
}
if (AlwaysBlocked.Contains(key))
{
return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다");
}
if (viaAdvancedRaw && BlockedInRawOnly.Contains(key))
{
return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요");
}
var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count();
if (types > 1 && BlockedWhenTypesDiffer.Contains(key))
{
return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요");
}
// 라디오의 기본 선택 — 같은 부모에 둘 이상이면 <b>한 문항의 답이 둘</b>이 된다.
// 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을
// 부모에 붙이기 <b>전에</b> 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다.
// 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다.
if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf))
{
return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다");
}
return BatchVerdict.Ok;
}
/// <summary>
/// 대상 <b>전부</b>가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건.
///
/// 둘 다 필요하다.
/// · <b>전부 갖고 있어야</b> 한다. 없던 대상에 키가 생기면 레거시 서식생성기가
/// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다.
/// · <b>전부 문자열이어야</b> 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다.
/// <c>GetText</c> 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀
/// <b>갈렸다는 표시조차 나오지 않는다</b> — 경고 없이 base64 원문이 교체된다.
/// </summary>
public static bool AllShareTextShape(string key, IReadOnlyList<ControlElement> targets)
{
foreach (var target in targets)
{
if (!target.Props.Contains(key))
{
return false;
}
if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue))
{
return false;
}
}
return true;
}
/// <summary>이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용</summary>
public static int CountHaving(string key, IReadOnlyList<ControlElement> targets)
=> targets.Count(t => t.Props.Contains(key));
/// <summary>선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다.</summary>
private static bool SameChoices(string[]? left, string[]? right)
{
if (left is null || right is null)
{
return left is null && right is null;
}
return left.SequenceEqual(right, StringComparer.Ordinal);
}
/// <summary>
/// 라벨만 바꾼 복제 — <see cref="PropertyDef"/> 는 record 가 아니라 <c>with</c> 를 쓸 수 없다.
/// 선택지 배열은 <b>복사하지 않고 참조만</b> 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다).
/// </summary>
private static PropertyDef Relabel(PropertyDef def, string label) => new()
{
Key = def.Key,
Label = label,
Editor = def.Editor,
Choices = def.Choices,
Default = def.Default,
};
/// <summary>같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가</summary>
private static bool HasRadioSiblings(IReadOnlyList<ControlElement> targets,
Func<ControlElement, ControlElement?>? parentOf)
{
var seen = new List<(ControlElement? Parent, int Count)>();
foreach (var target in targets)
{
if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal))
{
continue;
}
// 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다.
// 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다.
var parent = parentOf?.Invoke(target);
var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent));
if (index < 0)
{
seen.Add((parent, 1));
continue;
}
if (seen[index].Count + 1 >= 2)
{
return true;
}
seen[index] = (parent, seen[index].Count + 1);
}
return false;
}
#endregion
}
+3 -1
View File
@@ -66,7 +66,9 @@ public static class ControlRegistry
/// </summary> /// </summary>
public static PropertyDef DataTableField => new() public static PropertyDef DataTableField => new()
{ {
Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField, // 라벨에 '배선' 을 쓰지 않는다 — 전산실에서 배선은 랜선·전원이다.
// '값 가져오기' 는 방향이 이름에 들어 있어 형제 속성(데이터 태그)과도 구별된다.
Key = "DataTableField", Label = "값 가져오기", Editor = PropEditorKind.DataTableField,
}; };
} }
@@ -0,0 +1,77 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 미리보기에서 눌러 본 체크·라디오 상태 — <b>문서를 건드리지 않는다</b>.
///
/// <b>왜 별도 상태인가.</b> 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
/// 실제로 눌러 보며 확인한다. 우리 미리보기도 눌러 볼 수 있어야 하지만, 그 클릭이 디자인을
/// 바꾸면 "확인하려고 눌렀는데 서식이 수정됨" 이 된다. 그래서 컨트롤 Id → 상태 사전으로만 들고
/// 렌더 시점에 덮어쓴다. 미리보기를 닫으면 사라진다.
///
/// <b>배타 규칙은 직계 부모다.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식이다 —
/// 즉 <b>패널 하나가 문항 하나</b>다. 여기서도 형제는 직계 부모의 자식만 본다.
/// </summary>
public sealed class PreviewCheckState
{
#region Member Fields
private readonly Dictionary<string, bool> overrides = new(StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>눌러 본 것이 하나라도 있는가 — 렌더가 덮어쓸 필요가 있는지 판단용</summary>
public bool HasAny => overrides.Count > 0;
/// <summary>덮어쓸 상태 — 눌러 본 적 없으면 null</summary>
public IReadOnlyDictionary<string, bool> Overrides => overrides;
#endregion
#region Methods
/// <summary>이 컨트롤이 지금 체크된 상태인가 — 눌러 본 값이 없으면 서식의 기본값</summary>
public bool IsChecked(ControlElement element)
=> overrides.TryGetValue(element.Id, out var state)
? state
: string.Equals(element.Props.GetText("Checked"), "True", StringComparison.OrdinalIgnoreCase);
/// <summary>미리보기에서 누를 수 있는 타입인가</summary>
public static bool IsClickable(ControlElement element)
=> element.Type is "CheckBox" or "RadioButton"
// 레거시는 읽기전용(PreventEditing)이면 OnClick 자체를 무시한다
// (RadioButton.vb:271-279) — 값 대입은 되지만 클릭은 안 먹는다.
&& !string.Equals(element.Props.GetText("PreventEditing"), "True", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 눌렀다 — 체크박스는 반전, 라디오는 자기를 켜고 <b>직계 형제 라디오를 끈다</b>.
/// </summary>
/// <param name="target">누른 컨트롤</param>
/// <param name="siblings">그 컨트롤의 <b>직계 부모</b>가 담은 컨트롤 전부(자기 포함)</param>
public void Click(ControlElement target, IReadOnlyList<ControlElement> siblings)
{
if (!IsClickable(target))
{
return;
}
if (target.Type == "CheckBox")
{
overrides[target.Id] = !IsChecked(target);
return;
}
// 라디오 — 이미 켜져 있어도 다시 켠 상태로 둔다(레거시도 체크를 풀지 않는다).
// 끄는 것은 같은 부모의 다른 라디오를 누르는 것뿐이다.
foreach (var sibling in siblings)
{
if (sibling.Type == "RadioButton" && !ReferenceEquals(sibling, target))
{
overrides[sibling.Id] = false;
}
}
overrides[target.Id] = true;
}
/// <summary>눌러 본 것을 전부 잊는다 — 환자를 바꾸거나 서식을 다시 열 때</summary>
public void Clear() => overrides.Clear();
#endregion
}
@@ -0,0 +1,132 @@
namespace SheetMe.Core.Catalog;
/// <summary>이미지 경로를 어떻게 얻었는가 — 화면·진단이 사유를 정확히 말하기 위해 구분한다</summary>
public enum SignatureImageOrigin
{
/// <summary>쓸 수 있는 파일이 없다</summary>
None,
/// <summary>레거시 EMR 이 이미 내려받아 둔 캐시 파일</summary>
LegacyCache,
/// <summary>파일서버의 공유 폴더(UNC)로 직접 읽었다</summary>
Share,
/// <summary>DB 가 준 경로 자체가 이 단말에서 바로 열린다</summary>
DirectPath,
}
/// <summary>이미지 경로 판정 결과</summary>
/// <param name="Origin">어디서 얻었는가</param>
/// <param name="LocalPath">열 수 있는 파일의 전체 경로 — 없으면 빈 문자열</param>
/// <param name="FileName">원격 경로의 파일명(사유 문구·캐시 조회용)</param>
public readonly record struct SignatureImageResult(
SignatureImageOrigin Origin, string LocalPath, string FileName);
/// <summary>
/// DB 가 준 <b>원격 경로</b>에서 실제로 열 수 있는 <b>로컬 파일</b>을 정한다.
///
/// <b>왜 이 규칙인가.</b> 레거시는 원격 경로를 파일서버에서 내려받아 로컬 경로를 돌려준다
/// (bzDataInterface.vb 싸인 17종 공통). 그 전송 계층은 이 병원 설정에서 ActiveX OCX
/// (<c>AxBITSFTLib.AxbitSFT</c>, M_DtlMst 'EMR_FileTransfer' Type=1)라 .NET 으로 재현할 수 없다 —
/// OCX 내부 프로토콜이 코드에 없고, 우리 배포는 서드파티·ActiveX 를 금지한다.
///
/// 그래서 <b>받지 않고 찾는다</b>: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
/// (<c>{설치경로}\Tmp\DownLoadedFiles\Signature\{파일명}</c> — 레거시가 쓰는 바로 그 폴더),
/// ②DB 경로 자체가 이 단말에서 열리는 경우(로컬 절대경로·UNC 공유). 둘 다 아니면 없음이다.
///
/// 이 판정을 순수 함수로 두는 이유는 DB·파일서버 없이 규칙을 검증하기 위해서다.
/// </summary>
public static class SignatureImagePath
{
#region Methods
/// <summary>레거시가 쓰는 캐시 폴더 — 설치 경로가 없으면 빈 문자열</summary>
public static string CacheFolder(string? installPath)
=> string.IsNullOrWhiteSpace(installPath)
? string.Empty
// 레거시는 설치 경로가 없어도 문자열을 그대로 이어 붙여 "\Tmp\..." 라는
// 루트 상대경로를 만든다(clsCommonLib.vb:271-287). 그 결함은 옮기지 않는다.
: System.IO.Path.Combine(installPath.Trim(), "Tmp", "DownLoadedFiles", "Signature");
/// <summary>
/// 원격 경로 → 열 수 있는 로컬 파일.
/// </summary>
/// <param name="remotePath">DB 가 준 경로(M_UidMst.UidImgPth 등)</param>
/// <param name="installPath">HIS 설치 경로(ServerInfo ini 의 Install\Path)</param>
/// <param name="exists">파일 존재 판정 — 테스트가 갈아 끼운다</param>
/// <param name="shareMap">
/// 서버 경로 → UNC 공유 대응(<c>"D:\MsystechHIS=\\서버\MsystechHIS"</c> 형태).
/// 접두가 <b>긴 것부터</b> 맞춰 본다 — 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다.
/// </param>
public static SignatureImageResult Resolve(string? remotePath, string? installPath,
Func<string, bool>? exists = null, IReadOnlyList<string>? shareMap = null)
{
var remote = (remotePath ?? string.Empty).Trim();
if (remote.Length == 0)
{
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, string.Empty);
}
// 레거시는 역슬래시 기준으로 파일명을 뗀다(경로 구분자가 서버 규약이라 Path 로 자르지 않는다)
var fileName = remote.Split('\\', '/').Last();
var probe = exists ?? System.IO.File.Exists;
var folder = CacheFolder(installPath);
if (folder.Length > 0 && fileName.Length > 0)
{
var cached = System.IO.Path.Combine(folder, fileName);
if (probe(cached))
{
return new SignatureImageResult(SignatureImageOrigin.LegacyCache, cached, fileName);
}
}
// 캐시에 없으면 파일서버 공유(UNC)로 직접 읽는다 — 설정된 접두 대응을 쓴다.
// 이 경로가 이 작업의 목적이다: OCX 전송을 재현하는 대신 같은 파일을 공유로 본다.
if (MapToShare(remote, shareMap) is { Length: > 0 } shared && probe(shared))
{
return new SignatureImageResult(SignatureImageOrigin.Share, shared, fileName);
}
// 그래도 없으면 원격 경로가 이 단말에서 그대로 열리는지 본다
// (서버에서 실행하거나 같은 구조로 마운트된 단말)
if (probe(remote))
{
return new SignatureImageResult(SignatureImageOrigin.DirectPath, remote, fileName);
}
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, fileName);
}
/// <summary>
/// 서버 경로를 UNC 로 바꾼다 — 대응이 없으면 빈 문자열.
/// 대소문자를 무시하고, 접두가 <b>긴 것부터</b> 맞춘다.
/// </summary>
public static string MapToShare(string? remotePath, IReadOnlyList<string>? shareMap)
{
var remote = (remotePath ?? string.Empty).Trim();
if (remote.Length == 0 || shareMap is null || shareMap.Count == 0)
{
return string.Empty;
}
var best = string.Empty;
var bestLen = -1;
foreach (var entry in shareMap)
{
var at = entry.IndexOf('=', StringComparison.Ordinal);
if (at <= 0 || at == entry.Length - 1)
{
continue; // "서버접두=UNC접두" 가 아니면 무시한다(설정 오타를 값으로 바꾸지 않는다)
}
var from = entry[..at].Trim().TrimEnd('\\');
var to = entry[(at + 1)..].Trim().TrimEnd('\\');
if (from.Length == 0 || to.Length == 0 || from.Length <= bestLen)
{
continue;
}
if (remote.StartsWith(from + "\\", StringComparison.OrdinalIgnoreCase))
{
best = to + remote[from.Length..];
bestLen = from.Length;
}
}
return best;
}
#endregion
}
@@ -157,12 +157,16 @@ public static class TagPreviewCatalog
{ {
return TagPreviewKind.Table; return TagPreviewKind.Table;
} }
// 싸인·서명·직인·로고 — 값은 <b>이미지 파일 경로</b>다. 환자 해석기가 경로를 얻어
// 이 단말에서 파일을 찾고(레거시 캐시 → 경로 자체), PictureBox 가 그린다.
// 파일서버 전송(ActiveX)은 옮기지 않았으므로 캐시가 없는 단말에서는 사유가 된다.
// 분류는 PatientContext 다 — Image 로 두면 "글자만 보여 준다"는 <b>틀린 사유</b>가 나간다.
if (tag.Contains("싸인", StringComparison.Ordinal) if (tag.Contains("싸인", StringComparison.Ordinal)
|| tag.Contains("서명", StringComparison.Ordinal) || tag.Contains("서명", StringComparison.Ordinal)
|| tag.Contains("직인", StringComparison.Ordinal) || tag.Contains("직인", StringComparison.Ordinal)
|| tag.Contains("로고", StringComparison.Ordinal)) || tag.Contains("로고", StringComparison.Ordinal))
{ {
return TagPreviewKind.Image; return TagPreviewKind.PatientContext;
} }
return TagPreviewKind.PatientContext; return TagPreviewKind.PatientContext;
} }
@@ -14,6 +14,17 @@ public static class LegacyTypeCatalog
/// <summary>페이지 루트 클래스 단축명</summary> /// <summary>페이지 루트 클래스 단축명</summary>
public const string HostClassName = "MDesignerHost"; public const string HostClassName = "MDesignerHost";
/// <summary>
/// 데이터소스의 <b>중립</b> 타입명 — 레거시 클래스명은 <c>MDataTable</c> 이고 여기서 이 이름으로 바뀐다.
///
/// <b>상수로 두는 이유.</b> 두 이름이 공존해서 실제로 사고가 났다.
/// 배선 실행기가 레거시 이름(<c>MDataTable</c>)으로 컨트롤을 찾고 있었는데,
/// 서식을 XML 에서 읽으면 그 시점에 이미 중립 이름으로 바뀌어 있어 <b>한 개도 못 찾았다</b> —
/// 실사용 서식의 데이터소스 배선이 전부 "이 서식에 없습니다"로 죽었다.
/// 문자열을 각자 적는 한 같은 일이 또 난다.
/// </summary>
public const string DataTableType = "DataTable";
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary> /// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
public const string HostAqn = public const string HostAqn =
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; "M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
@@ -42,7 +53,7 @@ public static class LegacyTypeCatalog
["MPictureBox"] = "PictureBox", ["MPictureBox"] = "PictureBox",
["MCalcBox"] = "CalcBox", ["MCalcBox"] = "CalcBox",
["MButton"] = "Button", ["MButton"] = "Button",
["MDataTable"] = "DataTable", ["MDataTable"] = DataTableType,
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록 // Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
["Spread"] = "Spread", ["Spread"] = "Spread",
}; };
+14
View File
@@ -43,6 +43,20 @@ public sealed class DataConfig
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다. /// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
/// </summary> /// </summary>
public string DevUidCod { get; set; } = string.Empty; public string DevUidCod { get; set; } = string.Empty;
/// <summary>
/// 싸인·직인 이미지의 <b>서버 경로 → UNC 공유</b> 대응 — <c>"서버접두=UNC접두"</c> 목록.
/// 예: <c>D:\MsystechHIS=\\192.168.100.222\MsystechHIS</c>
///
/// <b>왜 설정인가.</b> DB 는 이미지 위치를 파일서버의 <b>로컬 절대경로</b>로 들고 있고
/// (실측: <c>D:\MsystechHIS\{사이트}\…</c>), 레거시는 그 경로를 ActiveX OCX 데몬
/// (기본 포트 2002)에 넘겨 받아 온다. 그 프로토콜은 재현할 수 없으므로 우리는 같은 파일을
/// <b>공유 폴더로 직접</b> 읽는다 — 접두 대응은 병원마다 다르니 코드가 아니라 설정이다.
///
/// 비어 있으면(기본) 매핑을 시도하지 않는다 — 지금까지와 같이 로컬 캐시만 본다.
/// <b>자격증명은 담지 않는다</b>: 공유 접근 권한은 단말 로그인 계정으로 해결한다.
/// </summary>
public string[] SignatureShareMap { get; set; } = Array.Empty<string>();
#endregion #endregion
#region Methods #region Methods
@@ -103,6 +103,31 @@ public static class ServerInfoReader
} }
} }
/// <summary>
/// HIS 설치 경로 — ini 의 <c>[MSYSTECHHIS\Install] Path</c>(레거시
/// <c>GetInstallPathFromServerInfoFile</c>, clsCommonLib.vb:271-287 과 같은 값).
/// 서명 이미지 캐시 폴더의 뿌리다. 이 값은 암호화되어 있지 않다(경로일 뿐).
/// ini 가 없거나 값이 비면 null — 레거시는 여기서 오류창을 띄우고 빈 문자열을 돌려줘
/// 폴더가 루트 상대경로("\Tmp\...")가 되어 버린다. 그 결함은 옮기지 않는다.
/// </summary>
public static string? InstallPath(string? iniPath = null)
{
var path = ResolveIniPath(iniPath);
if (path is null)
{
return null;
}
try
{
var value = ReadValue(File.ReadAllLines(path), @"MSYSTECHHIS\Install", "Path");
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
/// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary> /// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
private static string? ResolveIniPath(string? iniPath) private static string? ResolveIniPath(string? iniPath)
{ {
+158
View File
@@ -0,0 +1,158 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 싸인·직인·로고 태그가 쓰는 <b>원격 이미지 경로</b> 조회 — 레거시 bzDataInterface.vb 의
/// 싸인 태그 17종에서 기계 추출했다. 태그 본문은 전부 같은 골격이다:
/// 경로를 DB 에서 한 칸 얻고 → 파일서버에서 내려받아 → 로컬 경로를 돌려준다.
///
/// <b>여기서는 경로만 얻는다.</b> 내려받는 계층(clsFileTransfer)은 이 병원 설정에서
/// ActiveX OCX(M_DtlMst 'EMR_FileTransfer' Type=1)라 재현할 수 없다 — 그 판정과
/// 대안(레거시 캐시 사용)은 <see cref="Core.Catalog.SignatureImagePath"/> 에 있다.
///
/// 대부분의 원문이 ORDER BY 없는 Rows(0) 이라 정렬을 명시해 한 행으로 고정했다
/// (담당의 때 정한 규칙). 각 메서드 주석에 근거 줄번호를 남긴다.
/// </summary>
public sealed class SignatureStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public SignatureStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods
/// <summary>
/// 수술 집도의 싸인(OCM_수술집도의_싸인, :3385) — 최신 수술(OprKey DESC)의 집도의
/// (S_OprTyp 'DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 풀어 UidImgPth.
/// 원문의 OprStt 무필터·SYSDATE 유효기간을 그대로 옮긴다.
/// </summary>
public string SurgeonSignature(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'DTRUIDCOD'"
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 수술 마취의사 싸인(OCM_수술마취의사싸인_수술접수연동, :3235) — 같은 골격에
/// 오더 타입만 'ANEUIDCOD'(부서 조인 없음).
/// </summary>
public string AnesthesiaSignatureBySurgery(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'ANEUIDCOD'"
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 마취과 의사 싸인(OCM_수술마취의사싸인, :3311) — 환자와 무관하게 <b>마취과 소속</b>
/// 재직자 하나. 개원일 2021-03-01 이후 병원은 청구·그룹 코드로, 그 전은 부서명 LIKE
/// '%마취%' 로 찾는다(원문 주석: "코드로 하기엔 병원마다 다를거같아서").
/// 원문에 ORDER BY 가 없어 임의 행이었다 — 사용자 코드 순 첫 행으로 고정한다.
/// </summary>
public string AnesthesiaDepartmentSignature(bool byCode)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ (byCode
? " AND DepBilCod = '09' AND DepGrpCod = 'ANS'"
: " AND DepKorNam LIKE '%마취%'")
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
/// <summary>
/// 부서 코드로 찾는 의사 싸인 — 영상의학과(ETC_영상의학과의사싸인 :17353, 'XRAY')와
/// 진단검사(ETC_진단검사의사싸인 :17578) 계열의 공통형. 기준일은 작업일(없으면 오늘).
/// 원문 ORDER BY 없음 → 사용자 코드 순 고정.
/// </summary>
public string DepartmentDoctorSignature(string depCod, string day8)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE UidDtrYon = 'Y' AND UidDepCod = :d"
+ " AND :y BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
("d", depCod.Trim()), ("y", day8));
/// <summary>
/// 외출·외박 승인자 싸인(ETC_외출외박_확인간호사싸인 :17683 / _확인원무과싸인 :17765) —
/// 신청 행의 승인자 코드(병동 OutAdmWrdAppUid / 원무 OutAdmOffAppUid)를 오늘 기준
/// 사용자 마스터로 푼다. 원문 두 조회 모두 ORDER BY 없음 → 신청 시작일 최신으로 고정.
/// </summary>
public string OutingApproverSignature(decimal comNum, bool officeApprover)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM O_OutAdmInf"
+ " INNER JOIN M_UidMst ON UidCod = "
+ (officeApprover ? "OutAdmOffAppUid" : "OutAdmWrdAppUid")
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " WHERE OutAdmComNum = :m"
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm) DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 병동 수간호사 싸인(ETC_로그인사용자_병동_수간호사_싸인, :15562) — 이름 태그와 같은
/// 조회의 이미지 컬럼. 원문은 부서 코드를 문자열 연결로 넣는다 — 바인드로 정상화.
/// </summary>
public string WardHeadNurseSignature(string depCod)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE TRIM(UidDepCod) = :d"
+ " AND UidPstCod IN (SELECT c FROM (SELECT DtsCod c FROM M_DtsMst"
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
+ " AND DtsDtlCod = 'PSTCOD' AND DtsCodNam IN ('수간호사','간호수선생')"
+ " ORDER BY DtsCod) WHERE ROWNUM = 1)"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
("d", depCod.Trim()));
/// <summary>
/// 병원 로고(ETC_요양기관명칭_병원로고, :16538) — M_HspMst.HspLgoPth, 무기한 유효
/// (HspEndDte='29991231') 행. 원문은 0행이면 Rows(0) 예외였다 — 여기는 빈 값.
/// </summary>
public string HospitalLogo()
=> Scalar(
"SELECT v FROM (SELECT HspLgoPth v FROM M_HspMst"
+ " WHERE HspEndDte = '29991231' ORDER BY HspCod) WHERE ROWNUM = 1");
/// <summary>
/// 병원 직인(ETC_요양기관명칭_병원직인, :16580) — <b>병원 마스터가 아니라</b>
/// 설정 표(DB_REGISTRY, MnuNam='직인경로')의 SelYon 이다(원문에서 M_HspMst.HspStpPth
/// 조회는 주석 처리돼 있다). SCHILDREN·HSYUIL·JAH 는 즉시 빈 값 — 미이식 병원이다.
/// </summary>
public string HospitalStamp()
=> Scalar(
"SELECT v FROM (SELECT SelYon v FROM DB_REGISTRY"
+ " WHERE MnuNam = '직인경로' ORDER BY UidCod) WHERE ROWNUM = 1");
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열</summary>
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in binds)
{
command.Parameters.Add(new OracleParameter(name, value));
}
var result = command.ExecuteScalar();
return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
}
#endregion
}
+7 -3
View File
@@ -25,7 +25,9 @@ public sealed record HisUser(
/// <summary>영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래</summary> /// <summary>영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래</summary>
string EnglishName = "", string EnglishName = "",
/// <summary>세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호</summary> /// <summary>세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호</summary>
string DetailSpecialistNumber = "") string DetailSpecialistNumber = "",
/// <summary>싸인 이미지 원격 경로(UidImgPth) — 로그인 사용자 싸인 계열</summary>
string SignaturePath = "")
{ {
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary> /// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
@@ -85,7 +87,8 @@ public sealed class UserContextStore
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " + " NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " +
" NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " + " NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " +
" NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " + " NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " +
" NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' ') " + " NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' '), " +
" NVL(u.UidImgPth,' ') " +
"FROM M_UidMst u " + "FROM M_UidMst u " +
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " + "LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
@@ -109,7 +112,8 @@ public sealed class UserContextStore
reader.GetString(4).Trim(), reader.GetString(5).Trim(), reader.GetString(4).Trim(), reader.GetString(5).Trim(),
reader.GetString(6).Trim(), reader.GetString(7).Trim(), reader.GetString(6).Trim(), reader.GetString(7).Trim(),
reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(), reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(),
reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim()); reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim(),
reader.GetString(14).Trim());
} }
#endregion #endregion
} }
+6
View File
@@ -223,6 +223,12 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunSample(args[1], args[2]); return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
} }
// 싸인·이미지 태그의 전송 수단을 확정한다(값은 안 찍는다)
if (args.Length >= 2 && args[0] == "--db-filecfg")
{
return Diagnostics.DbSmoke.RunFileTransferConfig(args[1]);
}
if (args.Length >= 3 && args[0] == "--db-word-smoke") if (args.Length >= 3 && args[0] == "--db-word-smoke")
{ {
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]); return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
@@ -50,6 +50,13 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
switch (key) switch (key)
{ {
// Ctrl+방향키는 <b>크기</b>다 — 위치에는 미세 조정 수단이 있는데 크기만 없었다.
// 3px 늘리려고 300px 떨어진 인스펙터 W 칸까지 마우스로 왕복하던 것을 없앤다.
// Shift 를 함께 누르면 위치와 같은 큰 걸음(step)을 쓴다.
case Key.Left when ctrl: designer.ResizeSelection(-step, 0); break;
case Key.Right when ctrl: designer.ResizeSelection(step, 0); break;
case Key.Up when ctrl: designer.ResizeSelection(0, -step); break;
case Key.Down when ctrl: designer.ResizeSelection(0, step); break;
case Key.Left: designer.NudgeSelection(-step, 0); break; case Key.Left: designer.NudgeSelection(-step, 0); break;
case Key.Right: designer.NudgeSelection(step, 0); break; case Key.Right: designer.NudgeSelection(step, 0); break;
case Key.Up: designer.NudgeSelection(0, -step); break; case Key.Up: designer.NudgeSelection(0, -step); break;
@@ -68,11 +75,29 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
case Key.X when ctrl: designer.CutSelection(); break; case Key.X when ctrl: designer.CutSelection(); break;
case Key.V when ctrl: designer.Paste(); break; case Key.V when ctrl: designer.Paste(); break;
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break; case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
// Ctrl+Shift+Z 는 <b>다시 실행</b>이다. 이 갈래가 없어서 전에는 되돌리기가 한 번 더 됐다 —
// 방향이 반대인 줄 모르는 사용자는 "안 돌아오네" 하며 두세 번 더 눌러 편집분을 통째로 날린다.
// 실패가 조용히 성공하므로(되돌리기는 언제나 성공한다) 경고도 없다.
// 바로 아래 Key.G 가 이미 같은 짝을 가르고 있는데 Z 만 빠져 있었다.
case Key.Z when ctrl && shift: designer.Undo.Redo(); break;
case Key.Z when ctrl: designer.Undo.Undo(); break; case Key.Z when ctrl: designer.Undo.Undo(); break;
case Key.Y when ctrl: designer.Undo.Redo(); break; case Key.Y when ctrl: designer.Undo.Redo(); break;
case Key.G when ctrl && shift: designer.UngroupSelection(); break; case Key.G when ctrl && shift: designer.UngroupSelection(); break;
case Key.G when ctrl: designer.GroupSelection(); break; case Key.G when ctrl: designer.GroupSelection(); break;
// 선택 확장 — 정렬·간격 명령이 전제하는 '올바른 선택'을 만드는 수단.
// 마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 오고(실측 침입자 중앙값 7개),
// 그래서 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 붙었다.
case Key.D1 when alt: designer.SelectSame(DesignerViewModel.SameKind.Row); break;
case Key.D2 when alt: designer.SelectSame(DesignerViewModel.SameKind.Column); break;
case Key.D3 when alt: designer.SelectSame(DesignerViewModel.SameKind.Type); break;
case Key.D4 when alt: designer.SelectSame(DesignerViewModel.SameKind.Size); break;
// 잠금·숨김 — 상태와 레이어 패널의 자물쇠는 있었는데 캔버스에서 켤 방법이 없었다.
// 배경 괘선 40개를 잠그려면 레이어 트리에서 40번 눌러야 했다.
case Key.L when ctrl: designer.ToggleLockSelection(); break;
case Key.H when ctrl && shift: designer.ToggleHiddenSelection(); break;
// 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다. // 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다. // 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다. // 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
@@ -21,6 +21,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove += OnMouseMove; AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseUp; AssociatedObject.MouseLeftButtonUp += OnMouseUp;
AssociatedObject.LostMouseCapture += OnLostCapture; AssociatedObject.LostMouseCapture += OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown;
} }
protected override void OnDetaching() protected override void OnDetaching()
@@ -29,6 +30,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove -= OnMouseMove; AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseUp; AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
AssociatedObject.LostMouseCapture -= OnLostCapture; AssociatedObject.LostMouseCapture -= OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown;
base.OnDetaching(); base.OnDetaching();
} }
@@ -54,6 +56,44 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
e.Handled = true; e.Handled = true;
} }
/// <summary>
/// 우클릭 — 메뉴를 열기 <b>전에</b> 커서 아래 컨트롤을 선택한다.
///
/// 전에는 우클릭이 선택을 전혀 건드리지 않았다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를
/// 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 <b>A 를 지웠다</b>. 선택이 비어 있으면
/// 메뉴 항목이 전부 살아 있는 채로 아무 일도 하지 않았다.
/// 윈도우의 거의 모든 프로그램이 지키는 규약이라, 사용자는 메뉴가 가리키는 대상을
/// 확인할 생각조차 하지 않는다 — 20항목짜리 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.
///
/// <b>이미 선택 안에 있으면 선택을 유지한다</b> — 다중 선택을 우클릭해서 정렬하는 흐름을 깨면 안 된다.
/// <c>e.Handled</c> 는 두지 않는다. 그대로 흘려보내야 기존 ContextMenu 가 평소처럼 열린다.
/// </summary>
private void OnRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (Designer is not { } designer || designer.IsTabOrderMode)
{
return; // 탭순서 편집 중에는 클릭이 순번 지정이라 선택을 건드리지 않는다
}
FocusCanvasHost();
var world = e.GetPosition(AssociatedObject);
if (designer.HitTestControl(world) is { } hit)
{
if (!hit.IsSelected)
{
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
}
return;
}
// 빈 곳이면 선택을 푼다 — 다만 <b>잠긴/숨긴 것 위</b>는 빈 곳이 아니다.
// HitTestControl 이 그것들을 후보에서 아예 빼기 때문에(DesignerViewModel) 여기까지 오는데,
// 잠긴 배경 괘선 위에서 우클릭할 때마다 선택이 사라지면 "왜 자꾸 풀리지"가 된다.
if (!designer.AnyControlAt(world))
{
designer.Selection.Clear();
}
}
private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
if (Designer is not { } designer) if (Designer is not { } designer)
@@ -138,6 +138,58 @@ public static class LayerListBehavior
{ {
syncing.Remove(list); syncing.Remove(list);
} }
RevealPrimary(list, designer);
}
/// <summary>
/// 캔버스에서 고른 것을 목록이 <b>따라오게</b> 한다 — 조상을 펴고 그 행으로 스크롤한다.
///
/// <b>왜 필요한가.</b> 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면
/// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어
/// <b>행 자체가 만들어지지 않았거나</b>. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다.
/// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
///
/// <b>펴기는 한 번만 시도한다.</b> 편 결과로 목록이 다시 만들어지면 재구성 이벤트가
/// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다.
/// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 —
/// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다.
/// </summary>
private static void RevealPrimary(ListBox list, DesignerViewModel designer)
{
if (designer.Selection.Primary is not { } primary)
{
return;
}
if (RowOf(list, primary) is { } row)
{
list.ScrollIntoView(row);
return;
}
if (!designer.ExpandLayerPathTo(primary))
{
return; // 펼 것이 없는데도 행이 없다 = 필터에 걸린 것이다
}
// 재구성은 비동기로 도는 경로가 있다 — 한 틱 뒤에 다시 찾아본다
list.Dispatcher.BeginInvoke(new Action(() =>
{
if (RowOf(list, primary) is { } revealed)
{
list.ScrollIntoView(revealed);
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
/// <summary>이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다)</summary>
private static object? RowOf(ListBox list, ControlViewModel control)
{
foreach (var row in list.Items)
{
if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control))
{
return row;
}
}
return null;
} }
/// <summary>현재 선택으로부터 강조돼야 할 행들</summary> /// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
@@ -0,0 +1,25 @@
using System.Globalization;
using System.Windows.Data;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다.
///
/// 값이 갈린 다중 선택에서 <see cref="ViewModels.Inspector.ChoiceRowViewModel.EnsureMixedPlaceholder"/> 가
/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 <b>빈 줄</b>이 하나 보일 뿐이라
/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다.
/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야
/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다.
/// </summary>
public sealed class ChoiceLabelConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var text = value as string ?? string.Empty;
return text.Length == 0 ? "여러 값" : text;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -229,13 +229,28 @@
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/> <Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
</DataTemplate> </DataTemplate>
<!-- 이미지 --> <!-- 이미지 — 해석기가 파일을 찾았으면 그 이미지를, 아니면 자리표시자를 그린다.
종이(미리보기·인쇄)에서는 자리표시자 테두리가 보이면 안 되므로 이미지가 있으면
테두리 없이 이미지만 남긴다. -->
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}"> <DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False"> <Grid>
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0" <Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
HorizontalAlignment="Center" VerticalAlignment="Center" <Border.Style>
TextTrimming="CharacterEllipsis"/> <Style TargetType="Border">
</Border> <Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasImage}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
<Image Source="{Binding Image}" Stretch="{Binding ImageStretch}" IsHitTestVisible="False"/>
</Grid>
</DataTemplate> </DataTemplate>
<!-- 계산박스 --> <!-- 계산박스 -->
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Input;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 대화상자 크롬을 켠다 — <c>ThemedDialogWindow</c> 스타일이 <b>스타일 세터로</b> 걸어 준다.
///
/// <b>왜 첨부 속성인가.</b> 창 12종에 같은 타이틀바를 주려면 <see cref="ControlTemplate"/> 이 필요한데,
/// 템플릿 안의 닫기 버튼은 <c>Click</c> 핸들러를 가질 수 없다(템플릿은 코드비하인드가 없다).
/// 그래서 표준 <see cref="SystemCommands.CloseWindowCommand"/> 를 쓰고, 그 명령을 받을
/// <see cref="CommandBinding"/> 을 여기서 창에 달아 준다. 창마다 코드를 넣지 않아도 된다.
///
/// <b>최대화 보정도 함께 건다.</b> <c>WindowStyle=None</c> 인 창은 최대화하는 순간
/// 작업표시줄을 덮는다(메인 창에서 실제로 겪은 결함이다). 대화상자 대부분이 크기 조절 가능이라
/// 타이틀바를 더블클릭하면 그 상태에 들어갈 수 있다 — 크롬을 켜는 이 자리에서 같이 막는다.
/// </summary>
public static class DialogChrome
{
#region Attached Properties
/// <summary>크롬 사용 여부 — 스타일이 True 로 세팅한다</summary>
public static readonly DependencyProperty EnabledProperty =
DependencyProperty.RegisterAttached(
"Enabled", typeof(bool), typeof(DialogChrome),
new PropertyMetadata(false, OnEnabledChanged));
public static void SetEnabled(DependencyObject element, bool value)
=> element.SetValue(EnabledProperty, value);
public static bool GetEnabled(DependencyObject element)
=> (bool)element.GetValue(EnabledProperty);
#endregion
#region Methods
private static void OnEnabledChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
if (sender is not Window window || e.NewValue is not true)
{
return;
}
// 같은 창에 두 번 걸리지 않게 한다 — 스타일이 다시 적용되는 경로가 있다
foreach (CommandBinding existing in window.CommandBindings)
{
if (existing.Command == SystemCommands.CloseWindowCommand)
{
return;
}
}
// 크롬은 <b>창마다 새로</b> 만든다. WindowChrome 은 Freezable 이고 창에 붙으면서
// 스스로 속성을 고치므로, 한 인스턴스를 여러 창이 나눠 쓰면 두 번째 창부터
// "읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다.
// 그래서 XAML 스타일 세터로는 줄 수 없다(세터 값은 한 번 평가돼 공유된다).
//
// CaptionHeight 36 = 타이틀바 높이와 같아야 그 띠를 잡고 창을 끌 수 있다.
// GlassFrameThickness 0,0,0,1 = DWM 그림자 유지.
// ResizeBorderThickness 6 은 ResizeMode 가 허용할 때만 실제로 동작한다.
System.Windows.Shell.WindowChrome.SetWindowChrome(window, new System.Windows.Shell.WindowChrome
{
CaptionHeight = 36,
ResizeBorderThickness = new Thickness(6),
GlassFrameThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
UseAeroCaptionButtons = false,
});
window.CommandBindings.Add(new CommandBinding(
SystemCommands.CloseWindowCommand,
(_, args) =>
{
// 창의 X 를 누른 것과 같아야 한다 — DialogResult 를 건드리지 않으므로
// ShowDialog 는 false 를 돌려주고, 호출부의 '취소' 경로가 그대로 탄다
SystemCommands.CloseWindow(window);
args.Handled = true;
}));
// 크기 조절 가능한 창만 최대화될 수 있다 — 그때 작업표시줄을 덮지 않게 한다
if (window.ResizeMode is ResizeMode.CanResize or ResizeMode.CanResizeWithGrip)
{
if (window.IsInitialized)
{
Services.MaximizeToWorkArea.Attach(window);
}
else
{
window.SourceInitialized += (_, _) => Services.MaximizeToWorkArea.Attach(window);
}
}
}
#endregion
}
@@ -0,0 +1,76 @@
using System.Globalization;
using System.Windows.Markup;
using System.Windows.Media;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 이 PC 에 설치된 글꼴 이름 목록 — 인스펙터 글꼴 칸의 선택지.
///
/// <b>한글 이름을 낸다.</b> 레거시가 저장하는 값이 그것이다 — 실사용 서식의 Font 속성은
/// <c>"굴림, 11.25pt, style=Bold"</c> 처럼 GDI 가 주는 <b>지역화된</b> 이름을 담고 있다.
/// WPF 의 <see cref="FontFamily.Source"/> 는 같은 글꼴을 "Gulim" 으로 주므로,
/// 그걸 그대로 목록에 쓰면 사용자가 고른 순간 서식의 글꼴 이름이 영문으로 바뀐다.
/// 레거시가 그 이름을 못 찾으면 기본 글꼴로 떨어지고, 그 사실은 EMR 에서만 드러난다.
///
/// <b>목록에 없는 이름도 지운다는 뜻이 아니다.</b> 병원 PC 와 이 PC 의 설치 글꼴은 다르다.
/// 서식이 쓰는 글꼴이 여기 없을 수 있고, 그건 서식이 틀렸다는 뜻이 아니라
/// <b>지금 이 화면이 병원과 다르게 보인다</b>는 뜻이다 — 그래서 값을 고치지 않고 알리기만 한다.
/// </summary>
public static class InstalledFontCatalog
{
#region Member Fields
private static IReadOnlyList<string>? cached;
private static HashSet<string>? installed;
#endregion
#region Methods
/// <summary>설치된 글꼴 이름(한글 우선) — 가나다·알파벳 순, 한 번만 훑는다</summary>
public static IReadOnlyList<string> Families()
{
if (cached is not null)
{
return cached;
}
var names = new SortedSet<string>(StringComparer.Create(CultureInfo.GetCultureInfo("ko-KR"), ignoreCase: true));
foreach (var family in Fonts.SystemFontFamilies)
{
names.Add(DisplayNameOf(family));
}
cached = names.ToList();
installed = new HashSet<string>(cached, StringComparer.OrdinalIgnoreCase);
return cached;
}
/// <summary>이 PC 에 있는 글꼴인가 — 없으면 화면과 병원 출력이 다르다는 신호다</summary>
public static bool IsInstalled(string family)
{
if (family.Length == 0)
{
return true; // 미지정(상속)은 경고 대상이 아니다
}
Families();
return installed!.Contains(family);
}
/// <summary>
/// 이 글꼴의 표시 이름 — 한국어 이름이 있으면 그것, 없으면 원래 이름.
///
/// <c>FamilyNames</c> 는 글꼴이 스스로 담고 있는 언어별 이름표다. 한국어 글꼴은
/// ko-KR 항목에 "굴림"·"맑은 고딕" 을 갖고 있고, 레거시(GDI)가 쓰는 이름이 바로 그것이다.
/// </summary>
private static string DisplayNameOf(FontFamily family)
{
var korean = XmlLanguage.GetLanguage("ko-KR");
if (family.FamilyNames.TryGetValue(korean, out var name) && name.Length > 0)
{
return name;
}
// Source 는 "Gulim" 처럼 영문이거나, 조합 글꼴이면 "굴림, Arial" 같은 목록일 수 있다 —
// 목록이면 첫 이름만 쓴다(레거시 Font.Name 도 하나의 이름이다)
var source = family.Source;
var comma = source.IndexOf(',');
return comma < 0 ? source : source[..comma].Trim();
}
#endregion
}
+15 -1
View File
@@ -168,6 +168,15 @@ public static class LucideIcons
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true }; return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
} }
/// <summary>
/// 아이콘 색 칠하기 — 키가 있으면 리소스 참조(테마 추종), 없으면 넘겨받은 브러시.
///
/// <b>둘 다 없으면 검정이 아니라 B.Muted 다.</b> 전에는 <c>Brushes.Black</c> 으로 떨어졌는데,
/// 그건 다크 테마에서 <b>아이콘을 보이지 않게 만드는</b> 기본값이다.
/// 실제로 제목줄의 환자 아이콘이 그 경로로 혼자만 까맣게 묻혀 있었고,
/// 컴파일러가 CS8604 로 그 자리를 계속 가리키고 있었는데도 오래 남아 있었다.
/// 기본값을 테마 토큰으로 두면 어느 호출부도 같은 함정에 빠질 수 없다.
/// </summary>
private static void Paint(Path path, DependencyProperty property, Brush? color, string? brushKey) private static void Paint(Path path, DependencyProperty property, Brush? color, string? brushKey)
{ {
if (brushKey is not null) if (brushKey is not null)
@@ -175,7 +184,12 @@ public static class LucideIcons
path.SetResourceReference(property, brushKey); path.SetResourceReference(property, brushKey);
return; return;
} }
path.SetValue(property, color ?? Brushes.Black); if (color is not null)
{
path.SetValue(property, color);
return;
}
path.SetResourceReference(property, "B.Muted");
} }
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup) private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
+232 -1
View File
@@ -1570,6 +1570,184 @@ public static class DbSmoke
} }
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary> /// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
/// <summary>
/// 파일 전송 설정 진단(<c>--db-filecfg</c>) — 싸인·이미지 태그가 쓰는 전송 수단을 확정한다.
///
/// 레거시 <c>clsFileTransfer</c> 는 단일 프로토콜이 아니라 <b>5갈래</b>이고
/// (FileServer=1 · Database=2 · FileServerNDataBase=3 · SFTP=4 · FTP=5 ·
/// LCTech_Ver_Windows=6 · LCTech_Ver_Linux=7, clsFileTransfer.vb:188-196)
/// 어느 갈래인지는 소스가 아니라 <b>병원별 DB 설정</b>이 정한다
/// (M_DtlMst 'EMR_FileTransfer' 의 'Type', 기본값 3).
///
/// 이식 가능성이 여기서 갈린다 — Database(2)만 순수 .NET 으로 재현할 수 있고,
/// FileServer 계열은 ActiveX OCX, SFTP 는 WinSCP 어셈블리라 우리 배포 규칙
/// (서드파티 DLL 금지)과 충돌한다.
///
/// <b>비밀값은 찍지 않는다</b> — 어떤 키가 있는지와 값의 길이만 남긴다.
/// </summary>
public static int RunFileTransferConfig(string reportPath)
{
var lines = new List<string>();
var failed = 0;
void Check(string title, bool ok, string detail = "")
{
lines.Add((ok ? "PASS " : "FAIL ") + title + (detail.Length > 0 ? $" — {detail}" : string.Empty));
if (!ok)
{
failed++;
}
}
try
{
var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
// ① 전송 모드 — 이 한 줄이 이식 가능성을 가른다
var mode = string.Empty;
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT DtlCod, DtlCodNam FROM M_DtlMst WHERE DtlTblCod = 'EMR_FileTransfer' ORDER BY DtlCod";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var key = reader.GetValue(0)?.ToString()?.Trim() ?? string.Empty;
var value = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1).ToString()!.Trim();
// 접속 <b>대상</b>(Type·IP·Port·FileDirectory)은 값을 찍는다 — SMB 직접 접근
// 가능성을 판단하려면 주소와 공유 이름이 필요하다.
// <b>자격증명(User·Pwd)은 길이만</b> — 값은 어디에도 남기지 않는다.
var secret = key is "Pwd" or "User" or "Password" or "Account";
lines.Add(secret
? $" 설정 {key} = ({value.Length}자, 값은 기록하지 않는다)"
: $" 설정 {key} = {value}");
if (string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase))
{
mode = value;
}
}
}
var modeName = mode switch
{
"1" => "FileServer(ActiveX OCX)",
"2" => "Database(E_FilInf BLOB)",
"3" => "FileServerNDataBase(ActiveX OCX)",
"4" => "SFTP(WinSCP 어셈블리)",
"5" => "FTP(다운로드 분기 없음 — 항상 실패)",
"6" => "LCTech_Windows(ActiveX OCX)",
"7" => "LCTech_Linux(ActiveX OCX)",
"" => "설정 없음 → 레거시 기본값 3(FileServerNDataBase, ActiveX OCX)",
_ => $"알 수 없는 값({mode})",
};
lines.Add($" 전송 모드: {modeName}");
Check("① 전송 설정을 읽는다", true);
// ② 이미지 원본이 DB 에 있는가 — Database 모드면 여기 실체가 있다
var filCount = FirstText(config.ConnectionString, "SELECT COUNT(*) FROM E_FilInf");
lines.Add(filCount.Length == 0
? " E_FilInf: 없음(ORA-00942) — DB 보관 경로가 이 DB 엔 없다"
: $" E_FilInf: {filCount}행");
// ③ 서명 경로가 실제로 채워져 있는가 — 태그가 값을 낼 수 있는 환자가 있는지
var signed = FirstText(config.ConnectionString,
"SELECT COUNT(*) FROM M_UidMst WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' '");
var sample = FirstText(config.ConnectionString,
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ' ORDER BY UidCod) WHERE ROWNUM = 1");
lines.Add($" M_UidMst.UidImgPth 채워진 행: {signed}");
// 폴더 구조는 개인정보가 아니다 — 파일명(사용자 코드가 섞일 수 있다)만 뗀다.
// 이 접두가 FileDirectory(서버측 루트)와 어떻게 대응하는지가 SMB 매핑의 근거다.
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT folder, COUNT(*) c FROM (SELECT SUBSTR(UidImgPth, 1,"
+ " INSTR(UidImgPth, '\\', -1)) folder FROM M_UidMst"
+ " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ')"
+ " GROUP BY folder ORDER BY COUNT(*) DESC";
using var reader = command.ExecuteReader();
var shown = 0;
while (reader.Read() && shown < 5)
{
lines.Add($" 경로 접두: {reader.GetValue(0)} ({reader.GetValue(1)}건)");
shown++;
}
}
// 경로 <b>모양</b>만 남긴다 — 사람 이름이 섞일 수 있어 값 자체는 안 찍는다
var shape = sample.Length == 0 ? "없음"
: sample.StartsWith(@"\\", StringComparison.Ordinal) ? "UNC 공유(\\\\서버\\...)"
: sample.Contains(':') ? "드라이브 절대경로(C:\\...)"
: sample.Contains('\\') ? "역슬래시 상대경로"
: sample.Contains('/') ? "슬래시 상대경로" : "구분자 없음";
lines.Add($" 경로 모양: {shape} (길이 {sample.Length}자, 값은 기록하지 않는다)");
// ④ 공유(SMB) 직접 접근이 실제로 되는가 — OCX 전송을 대신할 유일한 경로다.
// 설정이 없으면 "무엇을 설정해야 하는지"를 알려 준다.
var map = config.SignatureShareMap;
if (map.Length == 0)
{
lines.Add(" 공유 매핑: 설정 없음(Images:SignatureShareMap)");
lines.Add(" → 예: \"D:\\MsystechHIS=\\\\<파일서버>\\<공유이름>\" (서버 경로 접두 = UNC 접두)");
}
else
{
foreach (var entry in map)
{
var at = entry.IndexOf('=', StringComparison.Ordinal);
var unc = at > 0 ? entry[(at + 1)..].Trim() : string.Empty;
var reachable = unc.Length > 0 && System.IO.Directory.Exists(unc);
lines.Add($" 공유 매핑: {entry} → 접근 {(reachable ? "" : "")}");
}
// 실제 경로 하나로 끝까지 확인한다 — 매핑이 맞아도 파일이 없으면 소용없다
if (sample.Length > 0)
{
var mapped = SheetMe.Core.Catalog.SignatureImagePath.MapToShare(sample, map);
lines.Add(mapped.Length == 0
? " 표본 경로: 매핑되는 접두가 없다(접두를 실제 저장 경로에 맞출 것)"
: $" 표본 경로 매핑 결과: 파일 {(System.IO.File.Exists(mapped) ? "" : "")}"
+ " (경로는 기록하지 않는다)");
}
}
// 파일서버가 SMB 를 열어 두었는지 — 닫혀 있으면 매핑을 아무리 맞춰도 안 된다.
// 주소는 접속 설정의 IP 를 그대로 쓴다(자격증명은 쓰지 않는다 — 단말 계정으로 붙는다).
var serverIp = FirstText(config.ConnectionString,
"SELECT DtlCodNam FROM M_DtlMst WHERE DtlTblCod = 'EMR_FileTransfer' AND DtlCod = 'IP'");
if (serverIp.Length > 0)
{
var open = false;
try
{
using var probe = new System.Net.Sockets.TcpClient();
open = probe.ConnectAsync(serverIp, 445).Wait(TimeSpan.FromSeconds(3));
}
catch
{
open = false;
}
lines.Add($" 파일서버 {serverIp} SMB(445): {(open ? "" : "")}");
if (!open)
{
lines.Add(" → 서버에서 SMB 공유를 열어야 이미지 직접 읽기가 가능하다"
+ "(전송 데몬 포트만 열려 있으면 우리는 못 받는다)");
}
}
// ⑤ 병원 로고·직인도 같은 축이다
var logo = FirstText(config.ConnectionString,
"SELECT COUNT(*) FROM M_HspMst WHERE HspLgoPth IS NOT NULL AND TRIM(HspLgoPth) <> ' '");
lines.Add($" M_HspMst.HspLgoPth 채워진 행: {(logo.Length == 0 ? " " : logo)}");
Check("② 이미지 원본·경로 실태를 읽는다", true);
}
catch (Exception ex)
{
Check("① 전송 설정을 읽는다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
lines.Add(string.Empty);
lines.Add(failed == 0 ? "결과: 전건 통과" : $"결과: 실패 {failed}건");
System.IO.File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
Console.WriteLine(string.Join(Environment.NewLine, lines));
return failed == 0 ? 0 : 1;
}
public static int RunSample(string tableName, string reportPath, int max = 12) public static int RunSample(string tableName, string reportPath, int max = 12)
{ {
try try
@@ -2088,7 +2266,7 @@ public static class DbSmoke
probeDoc.Pages.Add(probePage); probeDoc.Pages.Add(probePage);
var probeSource = new SheetMe.Core.Models.ControlElement var probeSource = new SheetMe.Core.Models.ControlElement
{ {
Type = "MDataTable", Id = "MDataTable1", Type = SheetMe.Core.Serialization.LegacyTypeCatalog.DataTableType, Id = "MDataTable1",
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 }, Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
}; };
// 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다 // 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다
@@ -2387,6 +2565,59 @@ public static class DbSmoke
"RPAD 매칭이나 NATCOD 조인을 의심할 것"); "RPAD 매칭이나 NATCOD 조인을 의심할 것");
} }
// ㊲ 싸인·직인·로고 경로 조회 — 파일서버 전송(ActiveX)은
// 옮기지 않았지만 <b>경로 조회는 전부 순수 SQL</b>이다.
// 컬럼·조인 이름이 틀리면 여기서 죽는다.
try
{
var signs = new SignatureStore(conn);
var probes = new (string Name, Func<string> Run)[]
{
("집도의", () => owner2 is null ? "-" : signs.SurgeonSignature(owner2.Value).Length.ToString()),
("마취(수술)", () => owner2 is null ? "-" : signs.AnesthesiaSignatureBySurgery(owner2.Value).Length.ToString()),
("마취(부서)", () => signs.AnesthesiaDepartmentSignature(true).Length.ToString()),
("영상의학", () => signs.DepartmentDoctorSignature("XRAY", "20260820").Length.ToString()),
("진단검사", () => signs.DepartmentDoctorSignature("LAB", "20260820").Length.ToString()),
("외출승인", () => owner2 is null ? "-" : signs.OutingApproverSignature(owner2.Value, false).Length.ToString()),
("수간호사", () => signs.WardHeadNurseSignature("0506").Length.ToString()),
("병원로고", () => signs.HospitalLogo().Length.ToString()),
("병원직인", () => signs.HospitalStamp().Length.ToString()),
};
var signShapes = new List<string>();
var failedSigns = new List<string>();
foreach (var (signName, run) in probes)
{
try
{
signShapes.Add($"{signName} {run()}");
}
catch (Exception ex)
{
var first = ex.Message.Split('\n')[0];
if (first.Contains("ORA-00942"))
{
signShapes.Add($"{signName} [표없음]");
}
else
{
failedSigns.Add($"{signName}: {first}");
}
}
}
lines.Add($" 싸인 경로: {string.Join(" · ", signShapes)} (값은 기록하지 않는다)");
foreach (var f in failedSigns)
{
lines.Add($" [진단] {f}");
}
Check("㊲ 싸인·직인·로고 경로 조회가 예외 없이 돈다", failedSigns.Count == 0,
"위 [진단] 줄의 컬럼·조인 이름을 의심할 것");
}
catch (Exception ex)
{
Check("㊲ 싸인·직인·로고 경로 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
// 나이 UDF — 주민번호가 있는 차트로 물으면 값이 나와야 한다. // 나이 UDF — 주민번호가 있는 차트로 물으면 값이 나와야 한다.
// (통합 대표행의 AgeMonth/AGE 파생 컬럼과 같은 식 — // (통합 대표행의 AgeMonth/AGE 파생 컬럼과 같은 식 —
// UDF 이름·접두가 틀리면 여기서 바로 죽는다) // UDF 이름·접두가 틀리면 여기서 바로 죽는다)
@@ -390,18 +390,26 @@ public static class DialogShots
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다 // 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
Pump(); Pump();
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고, // <b>창 전체</b>를 찍는다 — Content 만 찍으면 타이틀바가 통째로 빠진다.
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다). //
// 전에는 Content 를 찍었다. 그래서 창 크롬을 통일하기 전까지 이 진단은
// "열한 창이 윈도우 기본 타이틀바를 달고 뜬다"는 사실을 <b>한 번도 보여 준 적이 없었다</b> —
// 고쳐야 할 바로 그 부분에 눈이 없었던 셈이다. 이제 커스텀 타이틀바는 창의 Template
// 안에 있으므로 Content 만 찍으면 여전히 안 보인다.
//
// 배경을 먼저 칠하는 것은 그대로 둔다 — 투명 픽셀이 PNG 에서 검정으로 저장되면
// 라이트 테마 결함이 "다크처럼 보이는" 착시에 가려진다.
var content = (FrameworkElement)window.Content; var content = (FrameworkElement)window.Content;
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width); var root = (FrameworkElement?)window.Template?.FindName("PART_Root", window) ?? content;
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height); var width = (int)Math.Ceiling(window.ActualWidth > 0 ? window.ActualWidth : window.Width);
var height = (int)Math.Ceiling(window.ActualHeight > 0 ? window.ActualHeight : window.Height);
var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32); var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
var drawing = new DrawingVisual(); var drawing = new DrawingVisual();
using (var context = drawing.RenderOpen()) using (var context = drawing.RenderOpen())
{ {
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height)); context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height));
context.DrawRectangle(new VisualBrush(content) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top }, context.DrawRectangle(new VisualBrush(root) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
null, new Rect(0, 0, width, height)); null, new Rect(0, 0, width, height));
} }
bitmap.Render(drawing); bitmap.Render(drawing);
@@ -454,7 +462,7 @@ public static class DialogShots
{ {
var source = new SheetMe.Core.Models.ControlElement var source = new SheetMe.Core.Models.ControlElement
{ {
Type = "MDataTable", Id = id, Type = SheetMe.Core.Serialization.LegacyTypeCatalog.DataTableType, Id = id,
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 }, Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
}; };
source.Props.SetText("Query", query); source.Props.SetText("Query", query);
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,15 @@ public static class ConfigLoader
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty; config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
config.GridSize = ReadInt(root["Designer:GridSize"], 4); config.GridSize = ReadInt(root["Designer:GridSize"], 4);
config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6); config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6);
// 싸인 이미지 공유 매핑 — 배열(Images:SignatureShareMap:0…) 또는 세미콜론 한 줄 둘 다 받는다.
// 병원 배포는 ini·json 을 손으로 고치는 일이 많아 한 줄 형태가 실수를 줄인다.
config.SignatureShareMap = root.GetSection("Images:SignatureShareMap").GetChildren()
.Select(c => c.Value ?? string.Empty)
.Concat((root["Images:SignatureShareMap"] ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(v => v.Contains('=', StringComparison.Ordinal))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return config; return config;
} }
@@ -42,6 +42,12 @@ public sealed class InteractionController
private Rect groupBounds0; private Rect groupBounds0;
private readonly Dictionary<ControlViewModel, Rect> origBounds = new(); private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
private bool undoCaptured; private bool undoCaptured;
/// <summary>
/// 이번 드래그가 Ctrl+복사로 <b>사본을 만들었는가</b> — Esc 취소가 좌표만 되돌리면 안 되기 때문이다.
/// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다.
/// </summary>
private bool duplicatedThisDrag;
#endregion #endregion
#region Constructors #region Constructors
@@ -156,8 +162,29 @@ public sealed class InteractionController
if (!downControl.IsSelected) if (!downControl.IsSelected)
{ {
var mates = designer.GroupMatesOf(downControl); var mates = designer.GroupMatesOf(downControl);
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl); // Shift 는 이 제스처에서도 '더하기'다 — 기존 선택과 합친다(업 토글과 같은 규칙).
// Ctrl 은 '복사'라서 더하기가 아니다: 합집합으로 두면 아까 선택해 둔 컨트롤까지
// 같이 복제돼, 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다.
designer.Selection.Set(
ctx.Ctrl ? mates : designer.Selection.Items.Union(mates).ToList(), downControl);
} }
// Ctrl+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 <b>복제본이 끌린다</b>.
//
// <b>왜 여기서 복제하나.</b> 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다.
// 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다.
//
// <b>왜 붙여넣기가 아닌가.</b> Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를
// 그렇게 복사하면 사본이 패널 밖으로 나가 <b>배타 그룹이 깨진다</b>(레거시 라디오는
// 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다.
if (ctx.Ctrl && designer.DuplicateSelectionInPlace())
{
// 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다.
// 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는
// 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다.
undoCaptured = true;
duplicatedThisDrag = true;
}
// 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다
BeginMove(); BeginMove();
DoMove(world, ctx); // 전환 프레임부터 즉시 추종 DoMove(world, ctx); // 전환 프레임부터 즉시 추종
} }
@@ -206,20 +233,21 @@ public sealed class InteractionController
designer.ReassignPagesAfterMove(origBounds.Keys.ToList()); designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.ClearGuides(); designer.Overlay.ClearGuides();
designer.Snap.EndDrag(); designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break; break;
case Mode.Resize: case Mode.Resize:
designer.Overlay.ClearGuides(); designer.Overlay.ClearGuides();
designer.Snap.EndDrag(); designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break; break;
case Mode.Marquee: case Mode.Marquee:
var rect = RectFrom(downPoint, world); var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null); designer.Overlay.UpdateMarquee(null);
var hits = designer.ControlsIntersecting(rect) // 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
.SelectMany(designer.GroupMatesOf) // GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
.Distinct() var hits = designer.MarqueeHits(rect);
.ToList();
if (ctx.Ctrl || ctx.Shift) if (ctx.Ctrl || ctx.Shift)
{ {
var merged = designer.Selection.Items.Union(hits).ToList(); var merged = designer.Selection.Items.Union(hits).ToList();
@@ -235,6 +263,7 @@ public sealed class InteractionController
mode = Mode.None; mode = Mode.None;
downControl = null; downControl = null;
origBounds.Clear(); origBounds.Clear();
duplicatedThisDrag = false; // 이 드래그는 끝났다 — 다음 Esc 가 남의 복제를 물면 안 된다
designer.RefreshOverlay(); designer.RefreshOverlay();
} }
@@ -246,6 +275,29 @@ public sealed class InteractionController
{ {
if (mode is Mode.Move or Mode.Resize) if (mode is Mode.Move or Mode.Resize)
{ {
// Ctrl+드래그 복사를 취소하는 경우 — 좌표를 되돌리는 것으로는 부족하다.
// 사본은 이미 <b>문서에 들어가 있고</b>, 원래 자리로 되돌리면 원본과 픽셀 단위로 겹쳐
// 화면은 취소된 것처럼 보인다. 그대로 저장하면 종이에 같은 글자가 두 번 인쇄되고
// 증거는 "조금 굵어 보인다" 뿐이다. 그래서 복제 자체를 물린다.
//
// 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이
// 죽은 참조가 된다. 상태를 <b>먼저</b> 비우고 그다음에 되돌린다.
if (duplicatedThisDrag)
{
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
mode = Mode.None;
origBounds.Clear();
downControl = null;
duplicatedThisDrag = false;
undoCaptured = false;
designer.Undo.Undo();
// 취소는 "없던 일로" 다 — Ctrl+Y 로 사본이 되살아나면 취소가 아니다
designer.Undo.DiscardRedo();
designer.RefreshOverlay();
return;
}
foreach (var (vm, rect) in origBounds) foreach (var (vm, rect) in origBounds)
{ {
var offset = designer.ParentWorldOffset(vm); var offset = designer.ParentWorldOffset(vm);
@@ -393,6 +445,11 @@ public sealed class InteractionController
} }
designer.Selection.NotifyBoundsChanged(); designer.Selection.NotifyBoundsChanged();
// 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
if (designer.SelectionWorldBounds() is { } movedBounds)
{
designer.Overlay.SetDragBadge(movedBounds, resizing: false);
}
} }
private void DoResize(Point world, PointerContext ctx) private void DoResize(Point world, PointerContext ctx)
@@ -459,6 +516,10 @@ public sealed class InteractionController
} }
designer.Selection.NotifyBoundsChanged(); designer.Selection.NotifyBoundsChanged();
if (designer.SelectionWorldBounds() is { } sizedBounds)
{
designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
}
} }
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary> /// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
@@ -1,5 +1,6 @@
using SheetMe.Core.Catalog; using SheetMe.Core.Catalog;
using SheetMe.Core.Models; using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores; using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services; namespace SheetMe.Designer.Services;
@@ -141,7 +142,13 @@ public sealed class MDataTableRunner : IDataFieldResolver
{ {
foreach (var control in controls) foreach (var control in controls)
{ {
if (control.Type == "MDataTable") // <b>중립 타입명으로 비교한다.</b> 여기가 레거시 클래스명("MDataTable")을 보고 있어서
// 실사용 서식에서는 데이터소스를 <b>한 개도 못 모았다</b> — XML 에서 읽는 순간
// LegacyTypeCatalog 가 이미 "DataTable" 로 바꿔 두기 때문이다.
// 그 결과 모든 DataTableField 배선이 "데이터소스 X 이 이 서식에 없습니다"로 실패해
// 미리보기·인쇄에서 빈칸이 됐고, 미리보기의 '데이터소스...' 버튼은 영영 회색이었다.
// 진단들이 ControlElement 를 손으로 만들며 같은 레거시 이름을 써서 초록으로 통과하고 있었다.
if (string.Equals(control.Type, LegacyTypeCatalog.DataTableType, StringComparison.Ordinal))
{ {
queries[control.Id] = control.Props.GetText("Query") ?? string.Empty; queries[control.Id] = control.Props.GetText("Query") ?? string.Empty;
} }
@@ -204,6 +204,19 @@ public static class PatientIdentity
return () => connection.Length == 0 ? null : new PatientExtraStore(connection); return () => connection.Length == 0 ? null : new PatientExtraStore(connection);
} }
/// <summary>싸인·이미지 경로 조회 공급자 — 파일서버 전송은 옮기지 않았고 경로만 얻는다</summary>
public static Func<SignatureStore?> SignatureStoreSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0 ? null : new SignatureStore(connection);
}
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path)</summary>
public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath();
/// <summary>파일서버 공유 매핑 — 설정(Images:SignatureShareMap)</summary>
public static IReadOnlyList<string> ShareMap() => ConfigService.Current.SignatureShareMap;
/// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary> /// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
public static Func<PatientContextStore?> ContextStoreSource() public static Func<PatientContextStore?> ContextStoreSource()
{ {
@@ -76,7 +76,9 @@ public static class PatientSession
PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked)) PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap())
: session; : session;
return (tags, new MDataTableRunner(document, Variables())); return (tags, new MDataTableRunner(document, Variables()));
} }
@@ -228,6 +228,15 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly Dictionary<string, IReadOnlyDictionary<string, string>> specialDoctorCache = new(StringComparer.Ordinal); private readonly Dictionary<string, IReadOnlyDictionary<string, string>> specialDoctorCache = new(StringComparer.Ordinal);
/// <summary>싸인·이미지 경로 조회 공급자 — 태그가 물을 때 만든다</summary>
private readonly Func<SignatureStore?>? signatureStoreSource;
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더의 뿌리(ServerInfo ini)</summary>
private readonly string? installPath;
/// <summary>파일서버 공유 매핑(서버경로=UNC) — 설정에서 온다</summary>
private readonly IReadOnlyList<string>? shareMap;
/// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다</summary> /// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다</summary>
private readonly Func<PatientExtraStore?>? extraStoreSource; private readonly Func<PatientExtraStore?>? extraStoreSource;
@@ -265,7 +274,10 @@ public sealed class PatientTagResolver : ITagValueResolver
Func<PatientExtraStore?>? extraStoreSource = null, Func<PatientExtraStore?>? extraStoreSource = null,
Func<PatientContextStore?>? contextStoreSource = null, Func<PatientContextStore?>? contextStoreSource = null,
Func<string, string>? bstSource = null, Func<string, string>? bstSource = null,
Func<string, string, string[], string, string>? chartVitalSource = null) Func<string, string, string[], string, string>? chartVitalSource = null,
Func<SignatureStore?>? signatureStoreSource = null,
string? installPath = null,
IReadOnlyList<string>? shareMap = null)
{ {
this.context = context; this.context = context;
this.next = next; this.next = next;
@@ -286,6 +298,9 @@ public sealed class PatientTagResolver : ITagValueResolver
this.contextStoreSource = contextStoreSource; this.contextStoreSource = contextStoreSource;
this.bstSource = bstSource; this.bstSource = bstSource;
this.chartVitalSource = chartVitalSource; this.chartVitalSource = chartVitalSource;
this.signatureStoreSource = signatureStoreSource;
this.installPath = installPath;
this.shareMap = shareMap;
} }
#endregion #endregion
@@ -357,6 +372,10 @@ public sealed class PatientTagResolver : ITagValueResolver
{ {
return ageValue; return ageValue;
} }
if (FromSignature(tag) is { } signValue)
{
return signValue;
}
if (Computed.TryGetValue(tag, out var compute)) if (Computed.TryGetValue(tag, out var compute))
{ {
var made = compute(context); var made = compute(context);
@@ -1025,6 +1044,134 @@ public sealed class PatientTagResolver : ITagValueResolver
: new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다");
} }
/// <summary><see cref="FromSignature"/> 가 담당하는 이미지 태그 17종</summary>
private static readonly HashSet<string> SignatureTags = new(StringComparer.Ordinal)
{
"OCM_담당의사싸인", "OCM_진단서의사싸인", "OCM_로그인_사용자싸인",
"ETC_로그인사용자_서명", "ECT_로그인사용자싸인",
"OCM_수술집도의_싸인", "OCM_수술마취의사싸인", "OCM_수술마취의사싸인_수술접수연동",
"OCM_Cosign_싸인", "ETC_병리판독의사싸인", "ETC_영상의학과의사싸인", "ETC_진단검사의사싸인",
"ETC_요양기관명칭_병원로고", "ETC_요양기관명칭_병원직인",
"ETC_외출외박_확인간호사싸인", "ETC_외출외박_확인원무과싸인",
"ETC_로그인사용자_병동_수간호사_싸인",
};
/// <summary>
/// 싸인·직인·로고 17종 — <b>DB 는 경로만 준다</b>. 레거시는 그 경로를 파일서버에서
/// 내려받아 로컬 경로를 돌려주지만, 이 병원 설정의 전송 계층은 ActiveX OCX
/// (M_DtlMst 'EMR_FileTransfer' Type=1 — 진단 <c>--db-filecfg</c> 실측)라
/// 프로토콜이 코드에 없어 .NET 으로 재현할 수 없다(서드파티·ActiveX 배포 금지이기도 하다).
///
/// 그래서 <b>받지 않고 찾는다</b>: 레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
/// 폴더를 먼저 보고, 없으면 DB 경로 자체가 열리는지 본다
/// (<see cref="SignatureImagePath"/>). 둘 다 없으면 사유 — 종이에는 빈칸이다.
///
/// 값(경로)이 나오면 <b>PictureBox 가 그 파일을 그린다</b>(PrintService.WithTagValue).
/// </summary>
private TagValue? FromSignature(string tag)
{
if (!SignatureTags.Contains(tag))
{
return null;
}
if (extraCache.TryGetValue(tag, out var cached))
{
return cached;
}
var value = Compute();
extraCache[tag] = value;
return value;
TagValue Compute()
{
// ① 원격 경로를 얻는다 — 여기까지는 전부 순수 SQL 이라 이식이 끝난다
string remote;
try
{
if (RemotePath(tag) is not { } path)
{
return new TagValue(false, "이 태그의 이미지 경로 조회가 연결되지 않았습니다");
}
remote = path;
}
catch (Exception ex)
{
return new TagValue(false, $"이미지 경로 조회에 실패했습니다: {ex.GetType().Name}");
}
if (remote.Length == 0)
{
return new TagValue(false, "이 태그가 가리키는 이미지 경로가 등록되어 있지 않습니다");
}
// ② 그 경로의 파일을 이 단말에서 찾는다(레거시 캐시 → 경로 자체)
var found = SignatureImagePath.Resolve(remote, installPath, null, shareMap);
return found.Origin switch
{
SignatureImageOrigin.None => new TagValue(false,
$"이미지 파일을 이 단말에서 찾지 못했습니다({found.FileName})"
+ " — 파일서버 전송(ActiveX)은 옮기지 않았습니다"),
_ => new TagValue(true, found.LocalPath),
};
}
}
/// <summary>태그별 원격 경로 — 조회가 연결되지 않았으면 null</summary>
private string? RemotePath(string tag)
{
// 로그인 사용자 계열 3종은 세션이 이미 들고 있다(왕복 0)
if (tag is "OCM_로그인_사용자싸인" or "ETC_로그인사용자_서명" or "ECT_로그인사용자싸인")
{
return UserSession.Current.SignaturePath;
}
// 담당의는 프리페치한 행에 있다(왕복 0)
if (tag == "OCM_담당의사싸인")
{
return doctor.TryGetValue("UidImgPth", out var v) ? v.Trim() : string.Empty;
}
// 진단서 의사 = 로그인 의사면 본인, 아니면 담당의(:4904-5017 — 이름 계열과 같은 분기)
if (tag == "OCM_진단서의사싸인")
{
return UserSession.Current.DoctorYn == "Y"
? UserSession.Current.SignaturePath
: doctor.TryGetValue("UidImgPth", out var v) ? v.Trim() : string.Empty;
}
// 레거시 확정 결함 — 조회 SELECT 에 UidNam 만 있는데 UidImgPth 를 읽어
// 항상 예외→빈 값이었다(:17434-17513). 고치면 없던 싸인이 갑자기 찍힌다.
if (tag == "ETC_병리판독의사싸인")
{
return string.Empty;
}
// 저장된 기록 키(EmrKey)가 필요한 태그 — 미리보기에는 그 키가 없다
if (tag == "OCM_Cosign_싸인")
{
return string.Empty;
}
if (signatureStoreSource?.Invoke() is not { } store)
{
return null;
}
var comNum = PatientContext.Value(context.ComInf, "ComNum");
var hasVisit = decimal.TryParse(comNum, out var m);
var today = PatientContext.Value(hospital, "ServerToday");
var open = PatientContext.Value(hospital, "HspStrDte");
return tag switch
{
"OCM_수술집도의_싸인" => hasVisit ? store.SurgeonSignature(m) : string.Empty,
"OCM_수술마취의사싸인_수술접수연동" => hasVisit ? store.AnesthesiaSignatureBySurgery(m) : string.Empty,
// 개원일 2021-03-01 이후면 코드로, 그 전이면 부서명 LIKE(:3311-3384)
"OCM_수술마취의사싸인" => store.AnesthesiaDepartmentSignature(
string.CompareOrdinal(open, "20210301") > 0),
"ETC_영상의학과의사싸인" => store.DepartmentDoctorSignature("XRAY", today),
"ETC_진단검사의사싸인" => store.DepartmentDoctorSignature("LAB", today),
"ETC_외출외박_확인간호사싸인" => hasVisit ? store.OutingApproverSignature(m, false) : string.Empty,
"ETC_외출외박_확인원무과싸인" => hasVisit ? store.OutingApproverSignature(m, true) : string.Empty,
"ETC_로그인사용자_병동_수간호사_싸인" => store.WardHeadNurseSignature(UserSession.Current.DepCod),
"ETC_요양기관명칭_병원로고" => store.HospitalLogo(),
"ETC_요양기관명칭_병원직인" => store.HospitalStamp(),
_ => string.Empty,
};
}
/// <summary> /// <summary>
/// 나이 계열 7종(bzDataInterface.vb:359-508 + AgeCheck clsCommonLib.vb:665-974). /// 나이 계열 7종(bzDataInterface.vb:359-508 + AgeCheck clsCommonLib.vb:665-974).
/// ///
@@ -1742,6 +1889,14 @@ public sealed class PatientTagResolver : ITagValueResolver
"ETC_승인의사", "OCM_수술소독간호사", "ETC_승인의사", "OCM_수술소독간호사",
"OCM_적용진료과", "OCM_적용진료의사", "OCM_전과", "OCM_간호정보조사지_과거력", "OCM_적용진료과", "OCM_적용진료의사", "OCM_전과", "OCM_간호정보조사지_과거력",
"OCM_영상촬영이력", "OCM_영상촬영이력",
// 싸인·직인·로고 17종 — 값은 이미지 파일 경로다(FromSignature)
"OCM_담당의사싸인", "OCM_진단서의사싸인", "OCM_로그인_사용자싸인",
"ETC_로그인사용자_서명", "ECT_로그인사용자싸인",
"OCM_수술집도의_싸인", "OCM_수술마취의사싸인", "OCM_수술마취의사싸인_수술접수연동",
"OCM_Cosign_싸인", "ETC_병리판독의사싸인", "ETC_영상의학과의사싸인", "ETC_진단검사의사싸인",
"ETC_요양기관명칭_병원로고", "ETC_요양기관명칭_병원직인",
"ETC_외출외박_확인간호사싸인", "ETC_외출외박_확인원무과싸인",
"ETC_로그인사용자_병동_수간호사_싸인",
"OCM_수술주상병", "OCM_수술집도의_진료과", "OCM_수술집도의_그룹진료과", "OCM_수술주상병", "OCM_수술집도의_진료과", "OCM_수술집도의_그룹진료과",
"OCM_수술명칭", "OCM_수술진단명", "OCM_수술처치", "OCM_수술명칭", "OCM_수술진단명", "OCM_수술처치",
"OCM_퇴원약_코드", "OCM_퇴원약_명칭", "OCM_퇴원약_용량", "OCM_퇴원약_횟수", "OCM_퇴원약_코드", "OCM_퇴원약_명칭", "OCM_퇴원약_용량", "OCM_퇴원약_횟수",
+47 -13
View File
@@ -18,7 +18,8 @@ public static class PrintService
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary> /// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
public static void Print(DesignerViewModel designer, string documentName, public static void Print(DesignerViewModel designer, string documentName,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null) SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
var dialog = new System.Windows.Controls.PrintDialog(); var dialog = new System.Windows.Controls.PrintDialog();
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한 // 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
@@ -38,7 +39,7 @@ public static class PrintService
return; return;
} }
var document = BuildFixedDocument(designer, tags, fields); var document = BuildFixedDocument(designer, tags, fields, checks);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}"); dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
} }
@@ -53,7 +54,8 @@ public static class PrintService
/// </summary> /// </summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer, public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null) SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
var document = new FixedDocument(); var document = new FixedDocument();
foreach (var page in designer.Pages) foreach (var page in designer.Pages)
@@ -66,7 +68,7 @@ public static class PrintService
}; };
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) — // 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다. // 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields)); fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
var pageContent = new PageContent(); var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage); ((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -86,7 +88,8 @@ public static class PrintService
/// </summary> /// </summary>
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true, public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null) SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
var canvas = new Canvas var canvas = new Canvas
{ {
@@ -118,7 +121,7 @@ public static class PrintService
} }
var presenter = new ContentPresenter var presenter = new ContentPresenter
{ {
Content = PrintProjection(control, printFilter, tags, fields), Content = PrintProjection(control, printFilter, tags, fields, checks),
Width = Math.Max(1, control.Width), Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height), Height = Math.Max(1, control.Height),
}; };
@@ -149,11 +152,12 @@ public static class PrintService
/// </summary> /// </summary>
private static object PrintProjection(ControlViewModel control, bool printFilter, private static object PrintProjection(ControlViewModel control, bool printFilter,
SheetMe.Core.Catalog.ITagValueResolver? tags, SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields) SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
if (control is not ContainerViewModel container) if (control is not ContainerViewModel container)
{ {
return WithTagValue(control, tags, fields); return WithTagValue(control, tags, fields, checks);
} }
ContainerViewModel? copy = container switch ContainerViewModel? copy = container switch
{ {
@@ -173,7 +177,7 @@ public static class PrintService
{ {
continue; continue;
} }
if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected) if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
{ {
copy.Children.Add(projected); copy.Children.Add(projected);
} }
@@ -201,22 +205,44 @@ public static class PrintService
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다. /// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
/// </summary> /// </summary>
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags, private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields) SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
// 미리보기에서 눌러 본 체크·라디오는 <b>무엇보다 뒤다</b> — 태그가 만든 체크 상태까지 덮는다.
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
bool? clicked = null;
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
{
clicked = state;
}
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고, // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다 // 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다). // (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields); var (label, value) = Bound(control, tags, fields);
if (label is null) if (label is null)
{ {
return control; return clicked is null ? control : Rechecked(control, clicked.Value);
} }
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고 // 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면 // ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다. // "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel) if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{ {
return control; return clicked is null ? control : Rechecked(control, clicked.Value);
}
// 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
// 못 찾았으면 아무것도 넣지 않는다 — 자리표시자만 남고 종이에는 글자가 없다.
if (control is PictureBoxViewModel)
{
if (!value.Resolved)
{
return control;
}
var picture = control.Model.Clone();
picture.Props.SetText("ResolvedImagePath", value.Text);
return DocumentMapper.CreateControl(picture, control.EffectiveFont, control.Foreground);
} }
var model = control.Model.Clone(); var model = control.Model.Clone();
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel) if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
@@ -226,7 +252,7 @@ public static class PrintService
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString // 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다. // "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
var upper = value.Text.ToUpperInvariant(); var upper = value.Text.ToUpperInvariant();
model.Props.SetText("Checked", upper is "Y" or "TRUE" ? "True" : "False"); model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False");
} }
else else
{ {
@@ -236,6 +262,14 @@ public static class PrintService
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground); return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
} }
/// <summary>미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다</summary>
private static object Rechecked(ControlViewModel control, bool state)
{
var model = control.Model.Clone();
model.Props.SetText("Checked", state ? "True" : "False");
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
/// <summary> /// <summary>
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다. /// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
/// ///
+37 -1
View File
@@ -19,6 +19,9 @@ public sealed class UndoService
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary> /// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
private int savedDepth; private int savedDepth;
/// <summary>마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다</summary>
private bool lastDirty;
#endregion #endregion
#region Properties #region Properties
@@ -47,6 +50,12 @@ public sealed class UndoService
#region Events #region Events
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary> /// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
public event Action? Restored; public event Action? Restored;
/// <summary>
/// <see cref="IsDirty"/> 값이 <b>바뀌었을 때만</b> 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다.
/// 매번 통지하면 드래그 한 번에 수백 번 울린다.
/// </summary>
public event Action? DirtyChanged;
#endregion #endregion
#region Methods #region Methods
@@ -72,10 +81,15 @@ public sealed class UndoService
} }
redoStack.Clear(); redoStack.Clear();
lastNudgeAt = DateTime.MinValue; lastNudgeAt = DateTime.MinValue;
RaiseDirtyIfChanged();
} }
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary> /// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
public void MarkSaved() => savedDepth = undoStack.Count; public void MarkSaved()
{
savedDepth = undoStack.Count;
RaiseDirtyIfChanged();
}
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary> /// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge() public void SnapshotForNudge()
@@ -100,6 +114,27 @@ public sealed class UndoService
undoStack.RemoveAt(undoStack.Count - 1); undoStack.RemoveAt(undoStack.Count - 1);
restoreDocument(snapshot); restoreDocument(snapshot);
Restored?.Invoke(); Restored?.Invoke();
RaiseDirtyIfChanged();
}
/// <summary>
/// 다시 실행 이력을 버린다 — <b>취소</b>한 조작이 Ctrl+Y 로 되살아나면 안 될 때.
///
/// Esc 로 드래그를 취소하는 경우가 그렇다. 사용자는 "되돌린다"가 아니라 "없던 일로 한다"고
/// 생각하는데, 취소가 Undo 로 구현되어 있으면 그 조작이 redo 이력에 남아 되살아난다.
/// </summary>
public void DiscardRedo() => redoStack.Clear();
/// <summary>더티 값이 실제로 바뀐 경우에만 통지한다</summary>
private void RaiseDirtyIfChanged()
{
var now = IsDirty;
if (now == lastDirty)
{
return;
}
lastDirty = now;
DirtyChanged?.Invoke();
} }
/// <summary>다시 실행</summary> /// <summary>다시 실행</summary>
@@ -114,6 +149,7 @@ public sealed class UndoService
redoStack.RemoveAt(redoStack.Count - 1); redoStack.RemoveAt(redoStack.Count - 1);
restoreDocument(snapshot); restoreDocument(snapshot);
Restored?.Invoke(); Restored?.Invoke();
RaiseDirtyIfChanged();
} }
#endregion #endregion
} }
+85 -4
View File
@@ -72,7 +72,10 @@
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"> <!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -115,7 +118,10 @@
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"> <!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -132,8 +138,16 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.AccentFillPressed}" /> <Setter TargetName="bd" Property="Background" Value="{DynamicResource B.AccentFillPressed}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" /> <Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" />
</Trigger> </Trigger>
<!--
비활성은 <b>색을 빼서</b> 알린다. 전에는 불투명도만 0.45 로 내렸는데,
그러면 강조색이 옅어질 뿐 여전히 파랗고 그 위의 흰 글자가 배경에 묻힌다
(환자 선택 창의 '선택' 버튼이 그 상태로 뜬다 — 처음부터 비활성이다).
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"를 한꺼번에 없앤다.
-->
<Trigger Property="IsEnabled" Value="False"> <Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.45" /> <Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Input}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
</Trigger> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
@@ -739,7 +753,10 @@
<ControlTemplate TargetType="ToggleButton"> <ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"> <!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -1404,4 +1421,68 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<!-- ============================================================
대화상자 크롬 — 앱 셸·알림창과 같은 타이틀바를 <b>모든</b> 창에 준다.
<b>왜 필요했나.</b> 커스텀 크롬을 가진 창은 메인 셸과 MessageDialogView 둘뿐이었고,
나머지 열한 창(서식 이력·데이터소스 배선·글꼴 관리자·태그 선택·쿼리 편집기·환자 선택 …)은
윈도우 <b>기본</b> 타이틀바를 달고 떴다. 다크 테마에서 어두운 앱 위에 OS 바가 얹히고,
모서리·높이·글자 크기·닫기 버튼 모양이 전부 달라 "다른 프로그램이 열렸다"로 보였다.
<b>왜 스타일 하나로 하나.</b> 열한 창은 이미 전부 ThemedWindow 를 걸고 있었다 —
템플릿을 여기 한 번 두면 각 창은 스타일 이름 한 줄만 바꾸면 된다.
창마다 타이틀바 markup 을 복사하면 다음에 또 갈라진다.
AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져
13px 한글이 뭉개지고 진단이 찍는 PNG 배경이 알파가 된다.
닫기 버튼의 명령 배선과 최대화 보정은 ctl:DialogChrome 이 붙여 준다.
============================================================ -->
<Style x:Key="ThemedDialogWindow" TargetType="Window" BasedOn="{StaticResource ThemedWindow}">
<Setter Property="WindowStyle" Value="None" />
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
<!--
WindowChrome 은 여기서 못 준다. Freezable 인 데다 창에 붙는 순간 스스로 속성을 고치는데,
Style 의 Setter.Value 는 <b>한 번 평가돼 공유</b>되므로 두 번째 창부터
"읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다(x:Shared="False" 로도 안 된다 —
공유되는 것은 리소스가 아니라 Setter 가 이미 붙든 값이다).
그래서 ctl:DialogChrome 이 <b>창마다 새 인스턴스</b>를 만들어 붙인다.
-->
<Setter Property="ctl:DialogChrome.Enabled" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Window">
<!-- PART_Root — 진단(dialog-shots)이 창 전체를 찍을 때 잡는 이름.
이 이름이 없으면 Content 만 찍혀 타이틀바가 사진에서 빠진다. -->
<Border x:Name="PART_Root"
BorderBrush="{DynamicResource B.Line2}" BorderThickness="1"
Background="{DynamicResource B.Surface}">
<DockPanel>
<!-- 타이틀바 — 드래그는 WindowChrome 의 CaptionHeight 가 처리한다 -->
<Border DockPanel.Dock="Top" Height="36" Background="{DynamicResource B.Titlebar}"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<Grid>
<TextBlock Text="{TemplateBinding Title}" Margin="14,0,52,0"
VerticalAlignment="Center" FontSize="12"
Foreground="{DynamicResource B.Muted}"
TextTrimming="CharacterEllipsis" />
<!-- IsHitTestVisibleInChrome 필수 — 빠지면 클릭이 창 드래그로 먹혀 닫을 수 없다 -->
<Button Style="{StaticResource CloseBtn}" Height="36"
HorizontalAlignment="Right" IsTabStop="False" ToolTip="닫기"
Command="{x:Static SystemCommands.CloseWindowCommand}"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10" />
</Button>
</Grid>
</Border>
<!-- AdornerDecorator 가 있어야 검증 오류 표시·툴팁 어도너가 정상 동작한다 -->
<AdornerDecorator>
<ContentPresenter />
</AdornerDecorator>
</DockPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary> </ResourceDictionary>
@@ -199,6 +199,33 @@ public abstract class ControlViewModel : ViewModelBase, ILayerRow
/// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary> /// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary>
public bool HasLayerId => LayerId.Length > 0; public bool HasLayerId => LayerId.Length > 0;
/// <summary>
/// 레이어 행 둘째 줄 — <b>항상</b> 있다. 그래야 행 높이가 일정하다.
///
/// 전에는 표시명이 곧 Id 인 행(문구가 없는 컨트롤)에서 둘째 줄이 통째로 빠져
/// 목록에 1줄과 2줄이 섞였다. 훑을 때 리듬이 깨지고, 어느 행이 무엇인지
/// 눈이 매번 다시 자리를 잡아야 했다.
///
/// <b>타입명을 앞에 둔다.</b> 아이콘만으로는 타입이 안 갈리는 짝이 실제로 있다 —
/// 라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
/// 이름(Id)은 표시명과 다를 때만 뒤에 붙인다 — 같으면 윗줄과 같은 글자를 두 번 쓰는 셈이다.
/// 이름을 계속 보여 주는 이유는 그것이 <b>외부 참조 키</b>이기 때문이다(수식·액션 대상·이벤트 규약).
/// </summary>
public string LayerSubText
{
get
{
var typeName = SheetMe.Core.Catalog.ControlRegistry.Find(Type)?.DisplayName ?? Type;
// 이름이 곧 표시명이면(문구 없는 컨트롤) 타입만 — 같은 글자를 두 번 쓰지 않는다
if (!HasLayerId)
{
return typeName;
}
// 갓 놓은 컨트롤은 문구가 타입명 그대로다("라디오버튼") — 윗줄이 이미 타입을 말했으므로 이름만
return LayerName == typeName ? LayerId : $"{typeName} · {LayerId}";
}
}
/// <summary>레이어 잠금 토글 아이콘명</summary> /// <summary>레이어 잠금 토글 아이콘명</summary>
public string LockIcon => Model.Locked ? "lock" : "unlock"; public string LockIcon => Model.Locked ? "lock" : "unlock";
@@ -136,6 +136,30 @@ public abstract class ContainerViewModel : ControlViewModel
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary> /// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
public override bool HasLayerChildren => Children.Count > 0; public override bool HasLayerChildren => Children.Count > 0;
/// <summary>
/// 레이어 목록에 붙는 자식 수 — 접혀 있어도 <b>무엇이 딸려 있는지</b> 알 수 있어야 한다.
///
/// 페이지 행에는 (7), 그룹 폴더에는 (3)이 이미 붙는데 컨테이너만 없었다.
/// 그래서 접힌 패널이 3개를 품었는지 300개를 품었는지 펴 보기 전에는 알 수 없었고,
/// 그 패널을 지우거나 옮기기 전에 무엇이 함께 가는지도 알 수 없었다.
/// 중첩까지 세어 실제로 딸려 가는 수를 말한다.
/// </summary>
public string LayerChildCountText => $"({CountAll(Children)})";
private static int CountAll(IEnumerable<ControlViewModel> items)
{
var total = 0;
foreach (var item in items)
{
total++;
if (item is ContainerViewModel container)
{
total += CountAll(container.Children);
}
}
return total;
}
protected ContainerViewModel(ControlElement model) : base(model) { } protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary> /// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
@@ -180,6 +204,59 @@ public sealed class PictureBoxViewModel : ControlViewModel
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary> /// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
public string Hint => Prop("DataInterfaceTag") ?? "이미지"; public string Hint => Prop("DataInterfaceTag") ?? "이미지";
/// <summary>
/// 그릴 이미지 — 해석기가 정한 파일 경로(<c>ResolvedImagePath</c>)를 읽는다.
/// 캔버스(편집 화면)에는 그 속성이 없어 null 이고 자리표시자가 남는다.
///
/// <b>파일을 잠그지 않는다</b>(OnLoad + 스트림 복사). 레거시도 같은 이유로
/// Image.FromFile 대신 바이트를 읽어 메모리에서 디코딩한다(MPictureBox.vb:429-572) —
/// 잠그면 그 파일을 쓰는 다른 프로그램이 막힌다.
/// 못 읽으면 null 이다(깨진 파일·권한) — 레거시도 빈 catch 로 삼켜 빈칸이 됐다.
/// </summary>
public System.Windows.Media.ImageSource? Image
{
get
{
var path = Prop("ResolvedImagePath");
if (path is not { Length: > 0 })
{
return null;
}
try
{
var image = new System.Windows.Media.Imaging.BitmapImage();
image.BeginInit();
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
image.CreateOptions = System.Windows.Media.Imaging.BitmapCreateOptions.IgnoreImageCache;
image.UriSource = new Uri(path, UriKind.Absolute);
image.EndInit();
image.Freeze();
return image;
}
catch
{
return null;
}
}
}
/// <summary>
/// 채우기 방식 — 레거시 SizeMode 대응.
///
/// <b>인쇄는 레거시가 SizeMode 를 무시하고 컨트롤 사각형에 늘려 그린다</b>
/// (MPictureBox.vb:707-783 PrintMe/DrawingControl). 종이를 레거시와 맞추는 것이
/// 이 프로그램의 기준이므로 기본을 Fill 로 두고, 비율 유지를 명시한 값만 존중한다.
/// </summary>
public System.Windows.Media.Stretch ImageStretch => Prop("SizeMode") switch
{
"Zoom" => System.Windows.Media.Stretch.Uniform,
"CenterImage" or "AutoSize" or "Normal" => System.Windows.Media.Stretch.None,
_ => System.Windows.Media.Stretch.Fill,
};
/// <summary>이미지가 없을 때만 자리표시자를 보인다</summary>
public bool HasImage => Image is not null;
public PictureBoxViewModel(ControlElement model) : base(model) { } public PictureBoxViewModel(ControlElement model) : base(model) { }
} }
@@ -129,6 +129,21 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary> /// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary>
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName)); public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName));
/// <summary>
/// 저장하지 않은 변경이 있는가 — <b>탭에 점으로 보인다</b>.
///
/// 이 상태는 <see cref="UndoService.IsDirty"/> 가 저장 지점 깊이까지 추적해 정교하게 들고
/// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
/// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지
/// <b>확인할 방법이 없다</b> — 탭 제목이 저장 전후로 글자 하나 다르지 않다.
/// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 <b>새 버전을 만든다</b> —
/// 서식마다 쓸데없는 버전이 쌓인다.
/// </summary>
public bool IsDirty => Undo.IsDirty;
/// <summary>더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다</summary>
public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty));
/// <summary>페이지 VM 목록</summary> /// <summary>페이지 VM 목록</summary>
public ObservableCollection<PageViewModel> Pages { get; } = new(); public ObservableCollection<PageViewModel> Pages { get; } = new();
@@ -290,6 +305,8 @@ public sealed class DesignerViewModel : ViewModelBase
ScreenTolerance = ConfigService.Current.SnapThreshold, ScreenTolerance = ConfigService.Current.SnapThreshold,
}; };
Undo = new UndoService(() => Document, ReplaceDocument); Undo = new UndoService(() => Document, ReplaceDocument);
// 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다
Undo.DirtyChanged += NotifyDirtyChanged;
Interaction = new InteractionController(this); Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this); Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay; Selection.Changed += RefreshOverlay;
@@ -314,6 +331,15 @@ public sealed class DesignerViewModel : ViewModelBase
UngroupCommand = new Command((sender, e) => UngroupSelection()); UngroupCommand = new Command((sender, e) => UngroupSelection());
CutCommand = new Command((sender, e) => CutSelection()); CutCommand = new Command((sender, e) => CutSelection());
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both")); SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both"));
SelectSameCommand = new Command((object param) =>
{
if (Enum.TryParse<SameKind>(param as string, out var kind))
{
SelectSame(kind);
}
});
LockCommand = new Command((sender, e) => ToggleLockSelection());
HideCommand = new Command((sender, e) => ToggleHiddenSelection());
SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty)); SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty));
CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H")); CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H"));
TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode()); TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode());
@@ -371,6 +397,15 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary> /// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
public ICustomCommand? SizeToControlCommand { get; set; } public ICustomCommand? SizeToControlCommand { get; set; }
/// <summary>같은 행·열·종류·크기로 선택 넓히기 — 인자는 <see cref="SameKind"/> 이름</summary>
public ICustomCommand? SelectSameCommand { get; set; }
/// <summary>선택 잠그기/풀기</summary>
public ICustomCommand? LockCommand { get; set; }
/// <summary>선택 숨기기/보이기</summary>
public ICustomCommand? HideCommand { get; set; }
/// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary> /// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
public ICustomCommand? SpacingCommand { get; set; } public ICustomCommand? SpacingCommand { get; set; }
@@ -577,11 +612,58 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay(); RefreshOverlay();
} }
/// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary> /// <summary>
/// 문서 교체 후 재구성 — Undo 복원 경로.
///
/// <b>선택을 되살린다.</b> 재구성의 첫 줄이 <c>Selection.Clear()</c> 라, 전에는 되돌리기 한 번마다
/// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
/// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
/// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
///
/// 기준은 Id 다 — 문서 안에서 유일하고(<c>IdGenerator.CollectUsed</c>) 문서를 갈아끼워도 같다.
/// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
/// </summary>
public void ReplaceDocument(FormDocument newDocument) public void ReplaceDocument(FormDocument newDocument)
{ {
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
var primaryId = Selection.Primary?.Id;
document = newDocument; document = newDocument;
RebuildFromDocument(); RebuildFromDocument();
if (keepIds.Count == 0)
{
return;
}
var wanted = new HashSet<string>(keepIds, StringComparer.OrdinalIgnoreCase);
var found = new List<ControlViewModel>();
ControlViewModel? primary = null;
void Visit(IEnumerable<ControlViewModel> controls)
{
foreach (var vm in controls)
{
if (wanted.Contains(vm.Id))
{
found.Add(vm);
if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
{
primary = vm;
}
}
if (vm is ContainerViewModel container)
{
Visit(container.Children);
}
}
}
foreach (var page in Pages)
{
Visit(page.Controls);
}
if (found.Count > 0)
{
Selection.Set(found, primary ?? found[0]);
}
} }
/// <summary>페이지 세로 오프셋 재계산</summary> /// <summary>페이지 세로 오프셋 재계산</summary>
@@ -717,6 +799,37 @@ public sealed class DesignerViewModel : ViewModelBase
return HitInList(page.Controls, local); return HitInList(page.Controls, local);
} }
/// <summary>
/// 이 좌표에 <b>무엇이든</b> 있는가 — 잠긴 것·숨긴 것까지 포함해서.
///
/// <see cref="HitTestControl"/> 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다.
/// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다
/// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 <b>편집 보호</b>지 부재가 아니다.
/// </summary>
public bool AnyControlAt(Point world)
{
var page = PageAt(world.Y);
if (page is null)
{
return false;
}
return AnyInList(page.Controls, new Point(world.X, world.Y - page.OffsetY));
}
private static bool AnyInList(IList<ControlViewModel> controls, Point local)
{
foreach (var vm in controls)
{
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
return true;
}
return false;
}
private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local) private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local)
{ {
for (var i = controls.Count - 1; i >= 0; i--) for (var i = controls.Count - 1; i >= 0; i--)
@@ -751,30 +864,110 @@ public sealed class DesignerViewModel : ViewModelBase
var result = new List<ControlViewModel>(); var result = new List<ControlViewModel>();
foreach (var page in Pages) foreach (var page in Pages)
{ {
foreach (var vm in page.Controls) CollectIntersecting(page.Controls, worldRect, result);
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
{
result.Add(vm);
}
}
} }
return result; return result;
} }
/// <summary>
/// 마퀴에 걸리는 것을 모은다 — <b>컨테이너 안에 그린 마퀴는 그 안의 것을 고른다</b>.
///
/// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면
/// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
/// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다.
///
/// <b>규칙은 '담김'이다.</b> 마퀴가 컨테이너 사각형 <b>안에</b> 들어가 있으면
/// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까).
/// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다.
/// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다.
/// </summary>
private void CollectIntersecting(IEnumerable<ControlViewModel> controls, Rect worldRect,
List<ControlViewModel> result)
{
foreach (var vm in controls)
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
var bounds = WorldBoundsOf(vm);
if (!worldRect.IntersectsWith(bounds))
{
continue;
}
if (vm is ContainerViewModel container && bounds.Contains(worldRect))
{
CollectIntersecting(container.Children, worldRect, result);
continue;
}
result.Add(vm);
}
}
/// <summary>
/// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 <b>컨테이너 자식은 그대로 둔다</b>.
///
/// 클릭 선택은 <see cref="GroupMatesOf"/> 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다
/// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면
/// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다.
/// </summary>
public List<ControlViewModel> MarqueeHits(Rect worldRect)
{
var hits = ControlsIntersecting(worldRect);
var result = new List<ControlViewModel>();
foreach (var vm in hits)
{
// 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다
if (vm.Parent is null && vm.Model.GroupId is not null)
{
result.AddRange(GroupMatesOf(vm));
continue;
}
result.Add(vm);
}
return result.Distinct().ToList();
}
/// <summary>오버레이(선택 박스/핸들) 재계산</summary> /// <summary>오버레이(선택 박스/핸들) 재계산</summary>
public void RefreshOverlay() public void RefreshOverlay()
{ {
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0); Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
RefreshLockMarks();
if (Selection.Primary is not null) if (Selection.Primary is not null)
{ {
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage; Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage;
} }
} }
/// <summary>
/// 잠긴 컨트롤 표시를 다시 모은다 — 오버레이(편집 크롬) 층에만 그린다.
///
/// 종이 층에 넣으면 <c>--db-render</c> 게이트가 그것을 찍어 레거시와의 픽셀 동일성이 깨진다.
/// 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어, 클릭도 드래그도 안 되는 이유를
/// 화면에서 알 수 없었다.
/// </summary>
private void RefreshLockMarks()
{
Overlay.LockMarks.Clear();
foreach (var page in Pages)
{
foreach (var vm in ControlsOfPage(page))
{
if (!vm.Model.Locked || vm.Model.Hidden)
{
continue;
}
var world = WorldBoundsOf(vm);
Overlay.LockMarks.Add(new LockMarkInfo
{
X = world.X,
Y = world.Y,
W = Math.Max(1, world.Width),
H = Math.Max(1, world.Height),
});
}
}
}
#endregion #endregion
#region Events #region Events
@@ -1413,6 +1606,71 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows(); RebuildLayerRows();
} }
/// <summary>
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
///
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
///
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
/// </summary>
public bool DuplicateSelectionInPlace()
{
var sources = Selection.Items.ToList();
if (sources.Count == 0)
{
return false;
}
var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList());
if (pastable.Count == 0)
{
return false;
}
Undo.Snapshot();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
var copies = new List<ControlViewModel>();
foreach (var vm in sources)
{
if (!pastable.Contains(vm.Model))
{
continue;
}
var page = PageOf(vm);
if (page is null)
{
continue;
}
var clone = vm.Model.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
if (vm.Parent is ContainerViewModel parent)
{
// 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다
parent.Model.Children.Insert(0, clone);
var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground);
childVm.Parent = parent;
parent.Children.Add(childVm);
copies.Add(childVm);
}
else
{
page.Model.Controls.Insert(0, clone);
copies.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
}
if (copies.Count == 0)
{
return false;
}
Selection.Set(copies, copies.LastOrDefault());
RebuildLayerRows();
return true;
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary> /// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap) private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{ {
@@ -1588,6 +1846,156 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.NotifyBoundsChanged(); Selection.NotifyBoundsChanged();
} }
/// <summary>
/// 선택 확장의 기준 — 같은 줄로 볼 만큼 가까운가.
///
/// 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡힌다 — 8개 중 5개를 정렬해
/// 놓고 나머지 3개가 남은 것을 못 알아채는 것이 가장 나쁜 결과다. 2px 는 이 서식의
/// 컨트롤 높이(대부분 20px 이상)보다 훨씬 작아 이웃 줄을 삼키지 않는다.
/// 격자 크기에 묶지 않는다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
/// </summary>
private const double SelectSameTolerance = 2.0;
/// <summary>선택 확장의 종류 — 우클릭 메뉴·단축키가 공유한다</summary>
public enum SameKind
{
/// <summary>같은 행(Y 가 같다)</summary>
Row,
/// <summary>같은 열(X 가 같다)</summary>
Column,
/// <summary>같은 종류(타입이 같다)</summary>
Type,
/// <summary>같은 크기(너비·높이가 같다)</summary>
Size,
}
/// <summary>
/// 주 선택과 <b>같은 줄·열·종류·크기</b>인 형제를 선택에 더한다 — 반환은 더해진 개수.
///
/// <b>왜 필요한가.</b> 정렬 6종·간격 4종·같은 크기가 이미 다 있는데, 그 명령들이 전제하는
/// "올바른 선택"을 만들 수단이 없었다. 표 서식에서 가장 흔한 편집이
/// "이 행의 입력칸 8개를 조금 높이고 아래로 붙이자"인데, 그 8개를 고르는 길이
/// Ctrl+클릭 8번뿐이다. 마퀴는 <b>교차</b> 판정이라 행 띠를 스치는 것이 전부 딸려 오고,
/// 실측(787건)으로 행 무리의 94.2%에 침입자가 있으며 그 수가 중앙값 7개·p90 41개다.
/// 즉 지금은 "드래그 → 딸려온 것 빼기"가 매번 붙는다.
///
/// 범위는 <b>같은 부모의 형제</b>다 — 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있다.
/// 순수 계산이라 종이 렌더·저장 형식에 전혀 닿지 않는다.
/// </summary>
public int SelectSame(SameKind kind)
{
var anchor = Selection.Primary ?? Selection.Items.FirstOrDefault();
var page = (anchor is null ? null : PageOf(anchor)) ?? Selection.ActivePage ?? Pages.FirstOrDefault();
if (anchor is null || page is null)
{
return 0;
}
static bool Near(double left, double right) => Math.Abs(left - right) <= SelectSameTolerance;
var matches = SiblingsOf(anchor, page).Where(candidate => kind switch
{
SameKind.Row => Near(candidate.Y, anchor.Y),
SameKind.Column => Near(candidate.X, anchor.X),
SameKind.Type => string.Equals(candidate.Type, anchor.Type, StringComparison.Ordinal),
SameKind.Size => Near(candidate.Width, anchor.Width) && Near(candidate.Height, anchor.Height),
_ => false,
}).ToList();
// 잠금·숨김은 뺀다 — 편집하려고 넓히는 선택인데 편집할 수 없는 것이 섞이면
// 정렬을 눌렀을 때 "왜 이건 안 움직이지"가 된다
matches = matches.Where(c => !c.Model.Locked && !c.Model.Hidden).ToList();
var before = Selection.Items.Count;
var merged = Selection.Items.Union(matches).Distinct().ToList();
if (merged.Count == before)
{
return 0;
}
Selection.Set(merged, anchor);
return merged.Count - before;
}
/// <summary>
/// 선택 크기를 키우거나 줄인다 — <see cref="NudgeSelection"/> 의 빠져 있던 짝.
///
/// <b>왜 필요한가.</b> 위치는 방향키로 1px 미세 조정이 되는데 크기만 그 수단이 없었다.
/// 입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력한다 —
/// 캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이
/// 화면 6px 이라 작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.
///
/// 넛지와 같은 400ms 코얼레스를 쓴다 — 연타가 Undo 스무 개로 쌓이면 안 된다.
/// </summary>
public void ResizeSelection(double dw, double dh)
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.SnapshotForNudge();
foreach (var vm in Selection.Items)
{
if (vm.Model.Locked)
{
continue;
}
vm.Width = Math.Max(1, Math.Round(vm.Width + dw));
vm.Height = Math.Max(1, Math.Round(vm.Height + dh));
// 선은 두께 축을 끌어서 바꿀 수 없다 — 레거시 MLine 이 매번 BorderWidth 로 되돌린다.
// 허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.
if (SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(vm.Model))
{
vm.NotifyAllChanged();
}
}
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 선택을 잠그거나 푼다 — 하나라도 안 잠겼으면 전부 잠근다(레이어 패널의 묶음 토글과 같은 규칙).
///
/// <b>왜 캔버스에서 되어야 하나.</b> 배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때,
/// 지금은 좌측 패널을 레이어 탭으로 바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 한다.
/// 캔버스에서 마퀴로 40개를 잡아 놔도 한 번에 잠글 수단이 없었다.
///
/// <b>잠금 해제는 캔버스에서 못 한다.</b> 히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다 —
/// 해제 경로는 레이어 패널에 있다. 그래서 캔버스 메뉴 이름은 '선택 잠그기'로 한정한다.
/// <b>잠금·숨김은 저장되지 않는 편집 전용 상태다</b> — 영속처럼 읽히는 문구를 쓰지 않는다.
/// </summary>
public void ToggleLockSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
var locking = Selection.Items.Any(vm => !vm.Model.Locked);
foreach (var vm in Selection.Items)
{
vm.Model.Locked = locking;
vm.NotifyAllChanged();
}
RebuildLayerRows();
RefreshOverlay();
}
/// <summary>선택을 숨기거나 보인다 — 하나라도 보이면 전부 숨긴다</summary>
public void ToggleHiddenSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
var hiding = Selection.Items.Any(vm => !vm.Model.Hidden);
foreach (var vm in Selection.Items)
{
vm.Model.Hidden = hiding;
vm.NotifyAllChanged();
}
RebuildLayerRows();
RefreshOverlay();
}
/// <summary>활성 페이지 전체 선택</summary> /// <summary>활성 페이지 전체 선택</summary>
public void SelectAllOnActivePage() public void SelectAllOnActivePage()
{ {
@@ -1769,8 +2177,13 @@ public sealed class DesignerViewModel : ViewModelBase
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1; => Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary> /// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록. /// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다). /// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
/// 즉 이 조작이 "문항 만들기" 다.
/// </summary> /// </summary>
public void GroupSelection() public void GroupSelection()
{ {
@@ -1784,6 +2197,15 @@ public sealed class DesignerViewModel : ViewModelBase
DialogService.Notify(DialogKind.Warning, "그룹", reason); DialogService.Notify(DialogKind.Warning, "그룹", reason);
return; return;
} }
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
if (RadioSplitWarning(page, targets) is { } warning
&& !DialogService.Confirm("그룹", warning,
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
{
return;
}
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다. // z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데, // WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
@@ -1837,15 +2259,7 @@ public sealed class DesignerViewModel : ViewModelBase
/// </summary> /// </summary>
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets) private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{ {
// ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다). // 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
// '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다.
if (targets.Any(vm => vm.Type == "RadioButton"))
{
return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n"
+ "라디오는 그룹으로 묶지 마세요.";
}
// ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList(); var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
if (indices.Any(i => i < 0)) if (indices.Any(i => i < 0))
{ {
@@ -1860,6 +2274,32 @@ public sealed class DesignerViewModel : ViewModelBase
return null; return null;
} }
/// <summary>
/// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null.
///
/// <b>왜 되묻는가.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고 <b>직계 부모가 곧 배타 단위</b>다(RadioButton.vb:7-8).
/// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만,
/// 같은 자리에 있던 라디오 중 <b>일부만</b> 묶으면 한 문항이 두 묶음으로 갈라져
/// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로.
/// </summary>
internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{
var picked = targets.Count(vm => vm.Type == "RadioButton");
if (picked == 0)
{
return null;
}
// 지금은 최상위만 묶을 수 있으므로(위 targets 필터) 남는 형제도 최상위에서 센다
var siblings = page.Model.Controls.Count(c =>
string.Equals(c.Type, "RadioButton", StringComparison.Ordinal)
&& !targets.Any(vm => ReferenceEquals(vm.Model, c)));
return siblings == 0
? null
: $"이 페이지에 라디오 버튼 {picked + siblings}개 중 {picked}개만 묶습니다.\n"
+ $"남는 {siblings}개는 다른 선택 묶음이 됩니다. 계속할까요?";
}
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null; private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
/// <summary> /// <summary>
@@ -2220,6 +2660,19 @@ public sealed class DesignerViewModel : ViewModelBase
=> total.TryGetValue(id, out var t) && t > 0 => total.TryGetValue(id, out var t) && t > 0
&& selected.TryGetValue(id, out var s) && s == t; && selected.TryGetValue(id, out var s) && s == t;
// 조상 중에 '선택된 컨테이너'가 있는가 — 선택한 패널의 <b>범위</b>를 목록에 그리기 위한 판정
static bool HasSelectedContainerAncestor(ControlViewModel viewModel)
{
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
{
if (parent.IsSelected)
{
return true;
}
}
return false;
}
var states = new LayerHighlight[LayerRows.Count]; var states = new LayerHighlight[LayerRows.Count];
for (var i = 0; i < LayerRows.Count; i++) for (var i = 0; i < LayerRows.Count; i++)
{ {
@@ -2229,6 +2682,12 @@ public sealed class DesignerViewModel : ViewModelBase
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy, ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
ControlViewModel c when c.IsSelected => LayerHighlight.Blue, ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue, LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
// 선택한 컨테이너 안에 있는 것 — 그룹 판정보다 <b>뒤에</b> 둔다(그룹 색을 이기면 안 된다).
//
// 캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
// 밴드가 붙으면 "이걸 지우면 무엇이 같이 지워지나"가 선택 즉시 보인다 —
// 접었다 펴 보지 않아도 된다.
ControlViewModel c when HasSelectedContainerAncestor(c) => LayerHighlight.Navy,
_ => LayerHighlight.None, _ => LayerHighlight.None,
}; };
} }
@@ -2321,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다 /// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary> /// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
/// <summary>
/// 이 컨트롤의 행이 목록에 <b>나타나도록</b> 조상을 전부 편다 — 실제로 편 것이 있으면 true.
///
/// <b>왜 필요한가.</b> 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다.
/// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면
/// 목록에는 <b>행 자체가 없어</b> 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고,
/// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다.
/// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다.
///
/// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 <see cref="RevealControl"/> 로 이미 있다 —
/// 이것은 <b>그 역방향을 잇는 일</b>이지 새 기능이 아니다.
/// </summary>
public bool ExpandLayerPathTo(ControlViewModel viewModel)
{
var changed = false;
// 페이지 마디 — 공개 메서드를 쓴다(목록 재구성 요청까지 함께 도는 경로다)
if (PageOf(viewModel) is { } page && !IsPageExpanded(page))
{
SetPageExpanded(page, true);
changed = true;
}
// 그룹 폴더 — 그룹 멤버는 폴더가 접히면 행이 안 만들어진다
if (viewModel.Model.GroupId is { } groupId && !IsGroupExpanded(groupId))
{
SetGroupExpanded(groupId, true);
changed = true;
}
// 컨테이너 조상 — 자기 자신은 펴지 않는다(자기 행은 이미 있다)
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
{
if (!parent.IsLayerExpanded)
{
parent.IsLayerExpanded = true;
changed = true;
}
}
return changed;
}
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page); public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary> /// <summary>페이지 펼침/접기</summary>
@@ -388,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
var items = designer.Selection.Items; var items = designer.Selection.Items;
if (items.Count == 0) if (items.Count == 0)
{ {
RefreshTabCounts(null); RefreshTabCounts(Array.Empty<PropertyDef>(), 0);
if (designer.InspectedPage is { } page) if (designer.InspectedPage is { } page)
{ {
BuildPageRows(page); BuildPageRows(page);
@@ -397,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
return; return;
} }
// 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다 // 편집할 속성 집합을 <b>먼저</b> 정하고, 그 결과로 탭을 정한다.
var type = items[0].Type; //
var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null; // 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
RefreshTabCounts(descriptor); // 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다.
// 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이
// 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고
// 왜 없는지 알려 주는 표시도 없었다.
var models = items.Select(i => i.Model).ToList();
var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList();
var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null;
// MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이
// 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다.
var merged = BatchEditPolicy.MergeCommonDefs(types)
.Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed)
.ToList();
RefreshTabCounts(merged, items.Count);
if (selectedTab == InspectorTab.Design) if (selectedTab == InspectorTab.Design)
{ {
@@ -429,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
AddFontStyleSegment(); AddFontStyleSegment();
} }
// 타입 전용 — 선택 탭에 해당하는 속성만 // 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal); var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
if (descriptor is not null) if (merged.Count > 0)
{ {
// 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라 // 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라
// 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다. // 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다.
foreach (var def in descriptor.Properties) foreach (var def in merged)
{ {
curatedKeys.Add(def.Key); curatedKeys.Add(def.Key);
} }
var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList(); var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
if (tabDefs.Count > 0) if (tabDefs.Count > 0)
{ {
AddSection(descriptor.DisplayName); // 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
// 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
AddSection(types.Count == 1 && descriptor is not null
? descriptor.DisplayName
: $"공통 속성 ({types.Count}종 타입)");
foreach (var def in tabDefs) foreach (var def in tabDefs)
{ {
AddDefRow(def, descriptor.Type); // 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다
AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models);
}
// 함께 못 바꾸는 것이 있으면 <b>말한다</b>. 조용히 빼면 그 속성이 없다고 읽는다.
var excluded = BatchEditPolicy.ExcludedCount(types);
if (excluded > 0)
{
AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음",
$"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요"));
} }
} }
} }
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가). // 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가).
// 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다. // 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다.
if (items.Count == 1) // 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다.
if (!items.Any(i => DesignerViewModel.IsSpread(i.Model)))
{ {
BuildAdvancedRows(items[0], curatedKeys); BuildAdvancedRows(items, curatedKeys);
} }
OnPropertyChanged(nameof(IsTabEmpty)); OnPropertyChanged(nameof(IsTabEmpty));
@@ -469,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만 /// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다. /// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
/// </summary> /// </summary>
private void RefreshTabCounts(ControlDescriptor? descriptor) private void RefreshTabCounts(IReadOnlyList<PropertyDef> typed, int selectionCount)
{ {
var typed = descriptor?.Properties ?? (IReadOnlyList<PropertyDef>)Array.Empty<PropertyDef>(); // 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다.
// 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다 // 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 —
DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design); // 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다.
DesignCount = (selectionCount == 1 ? 11 : 10)
+ typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data); DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior); BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
@@ -548,7 +575,8 @@ public sealed class InspectorViewModel : ViewModelBase
// 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다 // 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
AddSection("기본 글꼴 (컨트롤이 상속)"); AddSection("기본 글꼴 (컨트롤이 상속)");
AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v,
isFamily: true);
AddPageFontRow(page, "크기(pt)", AddPageFontRow(page, "크기(pt)",
f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0
@@ -646,9 +674,12 @@ public sealed class InspectorViewModel : ViewModelBase
return row; return row;
} }
/// <summary>페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다</summary> /// <summary>
/// 페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다.
/// <paramref name="isFamily"/> 면 이름 칸이라 설치 글꼴 목록에서 고를 수 있게 한다.
/// </summary>
private void AddPageFontRow(PageViewModel page, string label, private void AddPageFontRow(PageViewModel page, string label,
Func<LegacyFont, string> get, Action<LegacyFont, string> set) Func<LegacyFont, string> get, Action<LegacyFont, string> set, bool isFamily = false)
{ {
var root = page.Model.Root; var root = page.Model.Root;
LegacyFont Current() LegacyFont Current()
@@ -657,7 +688,8 @@ public sealed class InspectorViewModel : ViewModelBase
return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont(); return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont();
} }
AddPageRow(page, new TextRowViewModel(label), () => get(Current()), value => PropertyRowViewModel row = isFamily ? NewFontFamilyRow(label) : new TextRowViewModel(label);
AddPageRow(page, row, () => get(Current()), value =>
{ {
var font = Current(); var font = Current();
set(font, value); set(font, value);
@@ -665,12 +697,41 @@ public sealed class InspectorViewModel : ViewModelBase
}, () => designer.RefreshPageInheritance(page)); }, () => designer.RefreshPageInheritance(page));
} }
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary> /// <summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys) /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가.
///
/// <b>다중 선택에서 위험한 곳이 여기다.</b> 전에는 행의 종류·편집 여부·키 목록을 전부
/// <c>items[0]</c> 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다. 그래서 이미지·중첩·참조 값을
/// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이
/// 안전해지거나 파괴적이 됐다. 게다가 <c>GetText</c> 는 문자열이 아닌 값에 null 을 돌려주므로
/// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 <b>갈렸다는 표시조차 나오지 않았다</b>.
///
/// 그래서 판정을 대상 <b>전체</b>로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다.
/// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다.
/// </summary>
private void BuildAdvancedRows(IReadOnlyList<ControlViewModel> targets, HashSet<string> curatedKeys)
{ {
var advancedKeys = target.Model.Props.Keys if (targets.Count == 0)
.Where(k => !curatedKeys.Contains(k)) {
.ToList(); return;
}
var target = targets[0];
var models = targets.Select(t => t.Model).ToList();
var single = targets.Count == 1;
// 키 목록은 <b>합집합</b>이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고,
// 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다.
var advancedKeys = new List<string>();
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var model in models)
{
foreach (var key in model.Props.Keys)
{
if (!curatedKeys.Contains(key) && seenKeys.Add(key))
{
advancedKeys.Add(key);
}
}
}
if (!showAdvanced) if (!showAdvanced)
{ {
@@ -692,6 +753,36 @@ public sealed class InspectorViewModel : ViewModelBase
foreach (var key in advancedKeys) foreach (var key in advancedKeys)
{ {
// 다중 선택은 대상 <b>전체</b>를 보고 판정한다 — 셋 다 통과해야 편집을 연다.
if (!single)
{
if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict)
{
AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})"));
continue;
}
var having = BatchEditPolicy.CountHaving(key, models);
if (having < models.Count)
{
// 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성'
// 이라며 모달을 띄운다 — 만들지 않는다
AddPlain(new ReadOnlyRowViewModel(key,
$"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)"));
continue;
}
if (!BatchEditPolicy.AllShareTextShape(key, models))
{
AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)"));
continue;
}
AddRow(new TextRowViewModel(key), new RowBinding
{
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
});
continue;
}
var value = target.Model.Props.Get(key); var value = target.Model.Props.Get(key);
switch (value) switch (value)
{ {
@@ -736,6 +827,13 @@ public sealed class InspectorViewModel : ViewModelBase
} }
} }
// 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 <b>하나</b> 기준이라
// 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
if (!single)
{
return;
}
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다). // 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 — // 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다. // 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
@@ -752,7 +850,16 @@ public sealed class InspectorViewModel : ViewModelBase
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys))); }, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
} }
private void AddDefRow(PropertyDef def, string controlType) /// <summary>
/// 컨트롤 모델 → 직계 부모 모델(최상위면 null).
///
/// 라디오 배타 판정에 쓴다 — 레거시 라디오는 <c>GroupName</c> 이 없어 <b>직계 부모가 곧 문항</b>이다.
/// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 <c>Parent</c> 를 돌려준다.
/// </summary>
private ControlElement? ParentModelOf(ControlElement model)
=> designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model;
private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList<ControlElement> targets)
{ {
var binding = def.Editor == PropEditorKind.StringList var binding = def.Editor == PropEditorKind.StringList
? new RowBinding ? new RowBinding
@@ -769,6 +876,7 @@ public sealed class InspectorViewModel : ViewModelBase
} }
vm.Model.Props.Set(def.Key, itemsValue); vm.Model.Props.Set(def.Key, itemsValue);
}, },
ConfirmBatch = ConfirmDestructive(def.Key),
} }
: new RowBinding : new RowBinding
{ {
@@ -793,6 +901,7 @@ public sealed class InspectorViewModel : ViewModelBase
vm.Model.Props.SetText(def.Key, value); vm.Model.Props.SetText(def.Key, value);
} }
}, },
ConfirmBatch = ConfirmDestructive(def.Key),
}; };
PropertyRowViewModel row = def.Editor switch PropertyRowViewModel row = def.Editor switch
@@ -809,24 +918,109 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label, PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags, "데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)), LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
// 액션은 값이 아니라 동작이다 — 피커가 데이터 태그용 값·사유 칸을 그리면 안 된다
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label, PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags, "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)), LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)) { IsActionTag = true },
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), // 컨트롤 이름을 넘긴다 — 안 넘기면 인스펙터로 연 편집기에서만 《컨트롤명》 칩이 사라져
// 같은 창이 진입 경로에 따라 능력이 달라진다
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label) { ControlNamesOf = ControlNames },
PropEditorKind.Mask => new MaskRowViewModel(def.Label), PropEditorKind.Mask => new MaskRowViewModel(def.Label),
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다 // 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames), PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames)
{
// 컬럼 목록과 대상 컨트롤 이름도 함께 준다 — 창이 사용자를 밖으로 내보내지 않게 한다
OwnerLabelOf = () => designer.Selection.Primary?.Id ?? string.Empty,
ColumnsOf = DataSourceColumns,
},
_ => new TextRowViewModel(def.Label), _ => new TextRowViewModel(def.Label),
}; };
AddRow(row, binding); // 선의 방향·굵기는 값만 바꾸는 것이 아니라 <b>크기까지</b> 바꾼다(ApplyLineChange).
// 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다.
AddRow(row, binding, def.Key is "Orientation" or "BorderWidth"
? () => designer.Selection.NotifyBoundsChanged()
: null);
} }
/// <summary>
/// 여러 대상의 <b>고유값을 한꺼번에 덮는</b> 편집에만 붙이는 확인 — 텍스트·항목 목록.
///
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고,
/// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다.
/// </summary>
private static Func<int, bool>? ConfirmDestructive(string key) => key is "Text" or "Items"
? count => Services.DialogService.Confirm("일괄 변경",
$"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.",
"각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).")
: null;
/// <summary> /// <summary>
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지. /// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
/// ///
/// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고, /// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
/// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다. /// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
/// </summary> /// </summary>
/// <summary>
/// 이 서식의 모든 컨트롤 이름 — 쿼리 편집기의 《컨트롤명》 치환 후보다.
///
/// 데이터소스 아이콘에서 편집기를 열면 이 목록이 넘어가는데 인스펙터의 쿼리 행에서는
/// 안 넘어가서, 같은 창이 진입 경로에 따라 능력이 달랐다.
/// </summary>
private IReadOnlyList<string> ControlNames()
{
var names = new List<string>();
void Visit(SheetMe.Core.Models.ControlElement element)
{
if (element.Id.Length > 0)
{
names.Add(element.Id);
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in designer.Document.Pages)
{
foreach (var control in page.Controls)
{
Visit(control);
}
}
names.Sort(StringComparer.Ordinal);
return names;
}
/// <summary>
/// 데이터소스의 조회 결과 컬럼 이름 — 못 읽으면 null(미접속·쿼리 실패).
///
/// 값 가져오기 창이 컬럼을 목록으로 보여 줄 수 있게 하는 재료다. 전에는 그 창이
/// "컬럼 후보는 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
///
/// 실행은 <see cref="Services.MDataTableRunner"/> 가 한다 — 접속·치환·캐시·실패 처리가
/// 이미 그 한 군데에 있고, 여기서 다시 만들면 두 벌이 된다.
/// 러너가 데이터소스별로 결과를 캐시하므로 창이 여러 번 물어도 조회는 한 번이다.
/// </summary>
private IReadOnlyList<string>? DataSourceColumns(string name)
{
try
{
var patient = Services.PatientSession.Current;
var runner = new Services.MDataTableRunner(designer.Document,
patient is null ? null
: new Services.PatientQueryVariableSource(patient, Services.UserSession.Current.UidCod));
var result = runner.Run(name);
return result.Ok && result.Columns.Count > 0 ? result.Columns : null;
}
catch
{
// 컬럼 목록은 <b>도움</b>이다 — 못 읽는다고 창이 안 열려서는 안 된다.
// 창은 null 을 받으면 이유를 적고 직접 입력 칸을 남긴다.
return null;
}
}
private IReadOnlyList<string> DataTableNames() private IReadOnlyList<string> DataTableNames()
{ {
var names = new List<string>(); var names = new List<string>();
@@ -905,6 +1099,8 @@ public sealed class InspectorViewModel : ViewModelBase
set(vm, Math.Round(number)); set(vm, Math.Round(number));
} }
}, },
// 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
AffectsVisual = false, AffectsVisual = false,
}; };
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged()); BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
@@ -912,9 +1108,16 @@ public sealed class InspectorViewModel : ViewModelBase
return row; return row;
} }
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴에서 고른다(직접 입력도 그대로 된다).
///
/// 전에는 자유 입력 칸 하나뿐이라 "돋움"을 매번 타이핑해야 했다. 그리고 한 글자만 틀려도
/// 레거시는 아무 말 없이 기본 글꼴로 떨어진다 — 디자이너에서는 멀쩡해 보이고
/// EMR 에서만 다른 글꼴로 나온다.
/// </summary>
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set) private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
{ {
AddRow(new TextRowViewModel(label), new RowBinding AddRow(NewFontFamilyRow(label), new RowBinding
{ {
Get = vm => get(vm.EffectiveFont), Get = vm => get(vm.EffectiveFont),
Set = (vm, value) => Set = (vm, value) =>
@@ -941,6 +1144,10 @@ public sealed class InspectorViewModel : ViewModelBase
set(font, value); set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
}, },
// 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
// "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
&& size > 0,
}); });
} }
@@ -975,6 +1182,16 @@ public sealed class InspectorViewModel : ViewModelBase
return new SegmentItem(icon, label, toggle); return new SegmentItem(icon, label, toggle);
} }
/// <summary>
/// 글꼴 이름 행 생성 — 컨트롤 글꼴과 페이지 기본 글꼴이 같은 것을 쓴다.
///
/// 목록은 <b>한글 이름</b>이다. 레거시가 저장하는 값이 그것이라("굴림, 11.25pt")
/// 영문 이름을 쓰면 고르는 순간 서식의 글꼴 이름이 바뀌고, 레거시가 그것을 못 찾으면
/// 조용히 기본 글꼴로 떨어진다.
/// </summary>
private static FontFamilyRowViewModel NewFontFamilyRow(string label)
=> new(label, SheetMe.Designer.Controls.InstalledFontCatalog.Families(), SheetMe.Designer.Controls.InstalledFontCatalog.IsInstalled);
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary> /// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
private static LegacyFont CurrentFontOf(ControlViewModel vm) private static LegacyFont CurrentFontOf(ControlViewModel vm)
{ {
@@ -1030,6 +1247,12 @@ public sealed class InspectorViewModel : ViewModelBase
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다. // 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
if (row is ChoiceRowViewModel choice) if (row is ChoiceRowViewModel choice)
{ {
// 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
// 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
if (values.Count > 1)
{
choice.EnsureMixedPlaceholder();
}
choice.EnsureChoice(initial); choice.EnsureChoice(initial);
} }
@@ -1042,6 +1265,27 @@ public sealed class InspectorViewModel : ViewModelBase
{ {
return; return;
} }
// ── 스냅샷 <b>앞</b>의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데
// 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다.
if (binding.Validate is { } validate && !validate(value))
{
// 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이
// 다음 재구성까지 남아 값인 척하는 것을 막는다
var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1);
return;
}
// 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다)
var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal))
{
return;
}
if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count))
{
row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1);
return;
}
// 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라 // 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다. // 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
if (row.CoalesceUndo) if (row.CoalesceUndo)
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary> /// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; } public bool IsMixed { get; private set; }
/// <summary>
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
///
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
/// </summary>
protected const string MixedSummary = "(여러 값)";
/// <summary> /// <summary>
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용. /// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼 /// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
get => valueText; get => valueText;
set set
{ {
// null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
// (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
// 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
// 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
value ??= string.Empty;
if (!SetProperty(ref valueText, value) || building) if (!SetProperty(ref valueText, value) || building)
{ {
return; return;
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
get => IsMixed ? null : ValueText == "True"; get => IsMixed ? null : ValueText == "True";
set set
{ {
if (value is bool on) if (value is not bool on)
{ {
ValueText = on ? "True" : "False"; return;
} }
// <b>갈린 상태에서 누르면 켠다.</b> WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
// IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
// 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
// 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
// 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이
// 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다.
if (IsMixed && !on)
{
on = true;
}
ValueText = on ? "True" : "False";
} }
} }
@@ -377,6 +402,47 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn)); protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
} }
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴 목록에서 고르거나 직접 입력한다.
///
/// <b>왜 자유 입력만으로는 안 되나.</b> 전에는 그냥 텍스트 칸이라 "돋움"을 매번 타이핑해야 했고,
/// 한 글자만 틀려도 조용히 기본 글꼴로 떨어졌다(레거시는 못 찾은 이름에 대해 아무 말도 하지 않는다).
///
/// <b>왜 목록만으로도 안 되나.</b> 병원 PC 와 이 PC 의 설치 글꼴이 다르다. 목록에 가두면
/// 여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고, 손대는 순간 사라진다.
/// 그래서 <b>고르기도 되고 쓰기도 되는</b> 칸이다.
/// </summary>
public sealed class FontFamilyRowViewModel : PropertyRowViewModel
{
private readonly Func<string, bool> isInstalled;
/// <summary>선택지 — 이 PC 에 설치된 글꼴</summary>
public IReadOnlyList<string> Families { get; }
/// <summary>
/// 지금 값이 이 PC 에 없는 글꼴인가 — 그러면 <b>이 화면이 병원과 다르게 보인다</b>.
/// 서식이 틀린 것이 아니므로 값은 건드리지 않고 알리기만 한다.
/// </summary>
public bool IsMissingFont => !IsMixed && ValueText.Length > 0 && !isInstalled(ValueText);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string MissingHint => $"'{ValueText}' 은(는) 이 PC 에 설치되어 있지 않습니다. "
+ "저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다.";
public FontFamilyRowViewModel(string label, IReadOnlyList<string> families, Func<string, bool> isInstalled)
: base(label)
{
Families = families;
this.isInstalled = isInstalled;
}
protected override void OnValueApplied()
{
OnPropertyChanged(nameof(IsMissingFont));
OnPropertyChanged(nameof(MissingHint));
}
}
/// <summary>선택지 행</summary> /// <summary>선택지 행</summary>
public sealed class ChoiceRowViewModel : PropertyRowViewModel public sealed class ChoiceRowViewModel : PropertyRowViewModel
{ {
@@ -402,6 +468,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
choices.Add(value); choices.Add(value);
} }
} }
/// <summary>
/// 값이 갈린 다중 선택에서 <b>'여러 값' 자리를 목록 맨 앞에 만든다</b>(빈 문자열).
///
/// 두 가지를 동시에 해결한다.
/// ① <b>보이게 한다.</b> 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
/// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
/// ② <b>코어스를 막는다.</b> <see cref="EnsureChoice"/> 는 IsNullOrEmpty 에서 빠져나가므로
/// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
/// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
/// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
///
/// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
/// </summary>
public void EnsureMixedPlaceholder()
{
if (!choices.Contains(string.Empty, StringComparer.Ordinal))
{
choices.Insert(0, string.Empty);
}
}
} }
/// <summary> /// <summary>
@@ -416,6 +503,29 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
/// <summary>피커 대화상자 제목</summary> /// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; } public string PickerTitle { get; }
/// <summary>
/// 액션 태그 행인가 — 액션은 <b>값이 아니라 동작</b>이라 피커의 값·사유 칸 뜻이 다르다.
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 그 칸이 거짓이 된다.
/// </summary>
public bool IsActionTag { get; init; }
/// <summary>
/// 카탈로그에 없는 이름인가 — <b>값은 건드리지 않고</b> 표식만 낸다.
///
/// 인라인 완성으로 직접 치는 것이 이 행의 주 경로인데 거기에는 검증이 하나도 없었다.
/// 'PAT_이룸' 처럼 한 글자 틀리면 아무 표시 없이 커밋되고, 그 사실은 미리보기에서
/// <b>빈칸</b>으로만 드러난다 — 빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 수 없다.
///
/// 막지는 않는다. 사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다(글꼴 행과 같은 규칙).
/// </summary>
public bool IsUnknownTag => !IsMixed && ValueText.Length > 0
&& !string.Equals(ValueText, "None", StringComparison.Ordinal)
&& !Choices.Contains(ValueText, StringComparer.Ordinal);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string UnknownTagHint => $"'{ValueText}' 은(는) 카탈로그에 없는 이름입니다. "
+ "사이트 전용 태그면 그대로 두고, 오타면 고치세요 — 값이 안 나오면 종이에서 빈칸이 됩니다.";
/// <summary> /// <summary>
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지. /// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다. /// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
@@ -589,13 +699,16 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
if (e.PropertyName == nameof(ValueText)) if (e.PropertyName == nameof(ValueText))
{ {
OnPropertyChanged(nameof(ShowSuggestions)); OnPropertyChanged(nameof(ShowSuggestions));
// 값이 바뀌면 '카탈로그에 없음' 표식도 다시 판정한다
OnPropertyChanged(nameof(IsUnknownTag));
OnPropertyChanged(nameof(UnknownTagHint));
} }
}; };
} }
private void OnBrowse() private void OnBrowse()
{ {
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll) var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll, IsActionTag)
{ {
Owner = System.Windows.Application.Current.MainWindow, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -613,7 +726,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public sealed class MaskRowViewModel : PropertyRowViewModel public sealed class MaskRowViewModel : PropertyRowViewModel
{ {
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary> /// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => ValueText.Length == 0 public string Preview => IsMixed
? MixedSummary
: ValueText.Length == 0
? "(마스크 없음)" ? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText); : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
@@ -648,6 +763,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
{ {
get get
{ {
if (IsMixed)
{
return MixedSummary;
}
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim(); var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine; return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
} }
@@ -663,9 +782,20 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary)); protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
/// <summary>
/// 이 서식의 컨트롤 이름 — 편집기의 《컨트롤명》 치환 후보가 된다.
///
/// 안 넘기면 <b>같은 창이 진입 경로에 따라 능력이 달라진다</b> —
/// 데이터소스 아이콘에서 열면 컨트롤 변수 칩이 나오는데
/// 인스펙터의 쿼리 행에서 열면 그 칩이 통째로 사라졌다.
/// 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
/// </summary>
public Func<IReadOnlyList<string>>? ControlNamesOf { get; init; }
private void OnEdit() private void OnEdit()
{ {
var dialog = new Views.QueryEditorWindow(Label, ValueText) var dialog = new Views.QueryEditorWindow(Label, ValueText,
showVariables: true, controlNames: ControlNamesOf?.Invoke())
{ {
Owner = System.Windows.Application.Current.MainWindow, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -838,26 +968,56 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{ {
private readonly Func<IReadOnlyList<string>> tablesOf; private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary> /// <summary>
/// 사람이 읽는 요약. <b>'배선' 이라는 낱말을 쓰지 않는다</b> —
/// 전산실에서 배선은 랜선·전원이고, '배선 없음' 은 데이터소스가 없는 것처럼 읽힌다.
/// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같게</b> 둔다 —
/// 그 버튼을 누르면 무엇이 되는지 미리 보이게 하려는 것이다.
/// </summary>
public string Summary public string Summary
{ {
get get
{ {
if (IsMixed)
{
return MixedSummary;
}
if (ValueText.Length == 0) if (ValueText.Length == 0)
{ {
return "(배선 없음)"; return "(가져오지 않음)";
} }
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText); var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
return spec is null return spec is null
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다 // 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
? $"⚠ 형식을 알 수 없음: {ValueText}" ? $"⚠ 형식을 알 수 없음: {ValueText}"
: $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행"); // 행 0 은 '첫 행' 이다. 그 밖은 사람이 세는 번호(0 → 1번째)로 적는다.
// 조건이 있으면 그 사실을 덧붙인다 — 조건이 화면 어디에도 안 보인 채
// 결과를 바꾸는 상태를 남기지 않는다.
: $"{spec.TableName} · {spec.Field}"
+ (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex + 1}번째 행")
+ (spec.Form == SheetMe.Core.Catalog.DataTableFieldForm.Select && spec.Filter.Length > 0
? " · 조건 있음" : string.Empty);
} }
} }
/// <summary>편집기 열기</summary> /// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; } public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
/// <summary>
/// 대상 컨트롤 이름 — 창 제목에 넣는다. 모달이라 인스펙터가 가려져서,
/// 여러 칸을 연달아 채울 때 지금 어느 칸 차례인지 알 방법이 없었다.
/// </summary>
public Func<string>? OwnerLabelOf { get; init; }
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름(없으면 null).
///
/// 전에는 창이 '컬럼 후보는 목록으로 제시하지 않습니다' 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
/// 실행은 이 행이 하지 않는다(호출부가 이미 만들어 둔 러너를 함수로 받는다).
/// </summary>
public Func<string, IReadOnlyList<string>?>? ColumnsOf { get; init; }
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label) public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
{ {
this.tablesOf = tablesOf; this.tablesOf = tablesOf;
@@ -873,7 +1033,8 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
private void OnEdit() private void OnEdit()
{ {
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf()) var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf(),
OwnerLabelOf?.Invoke(), ColumnsOf)
{ {
Owner = System.Windows.Application.Current.MainWindow, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -987,4 +1148,22 @@ public sealed class RowBinding
/// <summary>커밋 후 시각 재해석 필요 여부</summary> /// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true; public bool AffectsVisual { get; init; } = true;
/// <summary>
/// 커밋 전 값 검사 — false 면 <b>아무것도 하지 않고</b> 칸을 원래 표시로 되돌린다.
///
/// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
/// TryParse 에 실패해 아무것도 안 바뀌었다 — <b>바뀐 것이 없는데 문서는 '수정됨'</b>이 되고
/// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
/// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
/// </summary>
public Func<string, bool>? Validate { get; init; }
/// <summary>
/// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
///
/// 대상마다 <b>서로 다른 고유값</b>이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
/// </summary>
public Func<int, bool>? ConfirmBatch { get; init; }
} }
@@ -55,6 +55,22 @@ public sealed class SpacingMarkInfo
public double TextY { get; init; } public double TextY { get; init; }
} }
/// <summary>잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다.</summary>
public sealed class LockMarkInfo
{
/// <summary>좌상단 X(월드)</summary>
public double X { get; init; }
/// <summary>좌상단 Y(월드)</summary>
public double Y { get; init; }
/// <summary>너비(월드)</summary>
public double W { get; init; }
/// <summary>높이(월드)</summary>
public double H { get; init; }
}
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary> /// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
public sealed class TabBadgeInfo public sealed class TabBadgeInfo
{ {
@@ -100,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double selW; private double selW;
private double selH; private double selH;
private bool showHandles; private bool showHandles;
private string dragBadgeText = string.Empty;
private bool showDragBadge;
private double dragBadgeX;
private double dragBadgeY;
private bool hasMarquee; private bool hasMarquee;
private double marX; private double marX;
private double marY; private double marY;
@@ -172,6 +192,54 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary> /// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new(); public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
/// <summary>
/// 끄는 동안 보여 주는 치수·좌표 — <b>드래그 중에만</b> 있다.
///
/// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면
/// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다.
/// 간격 수치 칩은 <b>균등 스냅이 성립하는 순간에만</b> 나타나므로
/// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다.
/// </summary>
public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); }
/// <summary>치수 칩을 보일지</summary>
public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); }
/// <summary>치수 칩 X(월드) — 선택 상자 좌하단 바깥</summary>
public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); }
/// <summary>치수 칩 Y(월드)</summary>
public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); }
/// <summary>
/// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다.
/// 사용자가 지금 <b>바꾸고 있는 값</b>을 보여 주는 것이 요점이다.
/// </summary>
public void SetDragBadge(Rect bounds, bool resizing)
{
DragBadgeText = resizing
? $"{Math.Round(bounds.Width)} × {Math.Round(bounds.Height)}"
: $"X {Math.Round(bounds.X)} · Y {Math.Round(bounds.Y)}";
// 선택 상자 <b>아래</b>에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다
DragBadgeX = bounds.X;
DragBadgeY = bounds.Bottom + (6 * InverseZoom);
ShowDragBadge = true;
}
/// <summary>드래그가 끝났다 — 칩을 치운다</summary>
public void ClearDragBadge() => ShowDragBadge = false;
/// <summary>
/// 잠긴 컨트롤의 테두리 — <b>편집 크롬</b>이라 종이에는 그려지지 않는다.
///
/// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 <c>--db-render</c> 게이트가
/// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다.
///
/// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 <b>잠금은 아무 표시가 없었다</b>.
/// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다.
/// </summary>
public ObservableCollection<LockMarkInfo> LockMarks { get; } = new();
#endregion #endregion
#region Methods #region Methods
@@ -4,8 +4,8 @@
Title="데이터소스 조회 결과" Width="980" Height="640" Title="데이터소스 조회 결과" Width="980" Height="640"
MinWidth="760" MinHeight="440" MinWidth="760" MinHeight="440"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap" <TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"/> Foreground="{DynamicResource B.Muted}"/>
@@ -13,7 +13,7 @@
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="SQL 복사" Padding="14,4" Click="OnCopySql" <Button Content="SQL 복사" Padding="14,4" Click="OnCopySql"
ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/> ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/>
<Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True"/> <Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True" Style="{StaticResource Primary}"/>
</StackPanel> </StackPanel>
<Grid> <Grid>
@@ -1,29 +1,58 @@
<Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView" <Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터 필드 배선" Width="560" Height="520" Title="값 가져오기" Width="620" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="14"> <!--
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10" 이 창은 "조회 결과의 한 칸을 이 컨트롤에 채우는" 설정이다.
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
<!-- 아래쪽 버튼 --> <b>왜 다시 썼나.</b> 전에는 네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어서
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> 넷 다 채워야 하는 것처럼 보였다. 정작 <b>필수는 둘</b>(데이터소스·컬럼)이고
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/> 그 둘이 화면에서 가장 눈에 안 띄는 자리에 있었다.
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0" 게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 라,
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/> 화면이 "무엇을 먼저 하나"의 답을 '지우기' 로 주고 있었다.
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<!-- 조립 결과 미리보기 + 검증 메시지 --> 실사용 근거: 조건(필터)을 쓴 배선이 <b>0건</b>이고 행 번호는 98%가 0 이다.
그래서 그 둘은 접어 두고, 기본값이 아닐 때만 칩으로 드러낸다.
-->
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,12"
Text="서식이 열릴 때 데이터소스가 받아 온 조회 결과에서 한 칸을 읽어 이 칸을 자동으로 채웁니다. 고를 것은 둘입니다 — ① 어느 데이터소스에서, ② 그 결과의 어느 컬럼을."/>
<!-- 아래쪽 버튼. '가져오지 않음' 은 왼쪽으로 떼어 확인·취소와 섞이지 않게 한다 -->
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0" LastChildFill="False">
<Button DockPanel.Dock="Left" Content="가져오지 않음" Padding="12,4" Click="OnClear"
ToolTip="이 칸을 자동으로 채우지 않습니다 — 데이터소스나 쿼리는 지워지지 않습니다"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="OkButton" Content="확인" Padding="18,4"
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</DockPanel>
<!-- 사람 말 한 줄 + 접힌 저장 형식 -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6" <Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1"> Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
<StackPanel> <StackPanel>
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/> <TextBlock x:Name="PlainText" TextWrapping="Wrap" FontSize="12.5"/>
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0" <TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/> Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/>
<!--
저장 원문을 없애지 않고 접어 둔다. 인스펙터 툴팁·진단 덤프·레거시 문서가
전부 이 표기로 이야기되므로 원문을 들고 온 사람이 대조할 자리가 있어야 한다.
다만 큰 자리를 주면 창이 '문법 조립하는 곳' 으로 스스로를 재정의한다 —
아무것도 안 고른 상태의 첫 표시가 마침표로 시작하는 것도 그 탓이었다.
-->
<Expander x:Name="RawExpander" Margin="0,6,0,0" Header="저장 형식">
<DockPanel Margin="0,4,0,0">
<Button DockPanel.Dock="Right" Content="복사" Padding="8,2" VerticalAlignment="Top"
Margin="6,0,0,0" Click="OnCopyRaw"/>
<TextBlock x:Name="RawText" FontFamily="Consolas" FontSize="11.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"
ToolTip="레거시 XML 에 이 글자 그대로 들어갑니다"/>
</DockPanel>
</Expander>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -34,39 +63,67 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- 데이터소스 --> <!-- 데이터소스 -->
<DockPanel Grid.Column="0"> <DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"/> <TextBlock DockPanel.Dock="Top" Text="① 가져올 데이터소스" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap" <TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed" Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/> Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/>
<ListBox x:Name="TableList" SelectionChanged="OnFormChanged" FontSize="12.5"/> <!-- 도움말은 라벨 바로 아래다. Bottom 에 두면 목록이 열을 채워
설명이 목록에서 한참 떨어진 빈 공간에 떠 있었다. -->
<TextBlock DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,6"
Foreground="{DynamicResource B.Muted}"
Text="이 서식에 놓아 둔 조회 상자입니다 — 목록에서만 고릅니다."/>
<ListBox x:Name="TableList" SelectionChanged="OnTableChanged" FontSize="12.5"/>
</DockPanel> </DockPanel>
<!-- 나머지 조각 --> <!-- ② 컬럼 + ③ 접힌 행 고르기 -->
<StackPanel Grid.Column="2"> <DockPanel Grid.Column="2">
<TextBlock Text="형태" Margin="0,0,0,4"/> <TextBlock DockPanel.Dock="Top" Text="② 가져올 컬럼" FontWeight="SemiBold" Margin="0,0,0,4"/>
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged" <TextBox x:Name="FieldBox" DockPanel.Dock="Top" TextChanged="OnFormChanged"/>
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다."> <TextBlock x:Name="FieldHint" DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
<ComboBoxItem Content="Select — 필터로 행을 고름"/> Foreground="{DynamicResource B.Muted}"/>
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
</ComboBox>
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/> <!--
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged" ③ 은 접어 둔다. 조건을 쓴 실사용 배선이 0건이고 행 번호는 98%가 0 이라,
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/> 첫 방문자가 평생 안 쓸 개념을 먼저 보게 할 이유가 없다.
기본값이 아닐 때만 머리에 칩을 띄워 접혀 있어도 숨지 않게 한다.
-->
<Expander x:Name="RowExpander" DockPanel.Dock="Top" Margin="0,14,0,0">
<Expander.Header>
<StackPanel Orientation="Horizontal">
<TextBlock Text="③ 여러 행 중에서 고르기"/>
<Border x:Name="RowChip" Margin="8,0,0,0" Padding="6,1" CornerRadius="3"
Background="{DynamicResource B.Chip}" Visibility="Collapsed">
<TextBlock x:Name="RowChipText" FontSize="10.5"
Foreground="{DynamicResource B.Muted}"/>
</Border>
</StackPanel>
</Expander.Header>
<StackPanel Margin="0,8,0,0">
<TextBlock Text="몇 번째 행" Margin="0,0,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged"/>
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{DynamicResource B.Muted}"
Text="첫 행이 0 입니다. 1 은 두 번째 행입니다. 반각 숫자만 됩니다."/>
<TextBlock Text="행 번호" Margin="0,10,0,4"/> <!--
<TextBox x:Name="RowBox" TextChanged="OnFormChanged" ToolTip="0부터 셉니다"/> 조건은 저장된 값이 조건 형태일 때만 보인다. 새로 만들 때는 아예 안 보인다 —
실사용에서 조건을 쓴 배선이 0건이고, 이 문법은 SQL WHERE 가 아니라
<TextBlock Text="필드" Margin="0,10,0,4"/> 이미 받아 온 표를 거르는 식이라 NVL·TO_CHAR·|| 가 통하지 않는다.
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged" -->
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/> <StackPanel x:Name="FilterArea" Visibility="Collapsed">
<TextBlock Text="행 조건" Margin="0,12,0,4"/>
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0" <TextBox x:Name="FilterBox" TextChanged="OnFormChanged"/>
Foreground="{DynamicResource B.Muted}" <TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/> Foreground="{DynamicResource B.Muted}"
</StackPanel> Text="이 배선은 조건으로 행을 고르게 저장돼 있습니다. SQL 의 WHERE 가 아니라 받아 온 표를 거르는 식입니다 — 되는 예 CODCOD='A' · 안 되는 예 NVL, TO_CHAR, ||"/>
<Button x:Name="DropFilterButton" Content="조건을 버리고 순서대로 고르기"
Margin="0,8,0,0" Padding="10,3" Click="OnDropFilter"/>
</StackPanel>
</StackPanel>
</Expander>
</DockPanel>
</Grid> </Grid>
</DockPanel> </DockPanel>
</Window> </Window>
@@ -6,30 +6,74 @@ using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
/// <summary> /// <summary>
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다. /// 조회 결과의 한 칸을 컨트롤에 채우는 설정 — 문법 골격을 코드가 조립하고 저장 전에 검사한다.
/// ///
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해 /// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData). /// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%), ///
/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다. /// <b>새 배선은 Rows 형이다.</b> 전에는 레거시 편집기와 같은 Select 형으로 시작했는데,
/// 그러면 첫 화면이 굳이 <b>조건을 요구하는 어려운 쪽</b>이 된다.
/// 동작은 같다 — 조건이 비면 <c>Select("")(0)</c> 과 <c>Rows(0)</c> 이 같은 행을 가리키고,
/// 해석기 자신이 그 등가를 코드로 인정한다(DataTableFieldPicker 는 조건이 비어 있으면
/// 필터 경로를 아예 타지 않는다). 실사용에서 조건을 쓴 배선은 <b>0건</b>이다.
///
/// <b>기존 값의 형태는 그대로 유지한다</b> — 저장된 원문을 우리가 바꾸지 않는 것이
/// XML 왕복 무손실의 조건이다. Select→Rows 전환은 조건을 버리는 일이라
/// 명시적 버튼으로만 하고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
/// </summary> /// </summary>
public partial class DataTableFieldDialogView : Window public partial class DataTableFieldDialogView : Window
{ {
#region Member Fields #region Member Fields
private readonly IReadOnlyList<string> tables; private readonly IReadOnlyList<string> tables;
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름. 없으면 null(오프라인·미접속).
///
/// <b>여기가 이 창의 유일한 작업 포기 지점이었다.</b> 전에는 "필드 후보는 쿼리를 실행해야
/// 알 수 있어 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 창 밖으로 보냈는데,
/// 이 창은 모달이라 밖이 잠겨 있다 — 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
/// 컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로 그 문구는
/// 퇴행을 설명하는 문장이었다.
///
/// 실행 자체는 이 창이 하지 않는다 — 호출부가 이미 만들어 둔 러너를 함수로 감싸 넘긴다.
/// 그래야 접속·캐시·실패 처리가 한 군데(MDataTableRunner)에만 있다.
/// </summary>
private readonly Func<string, IReadOnlyList<string>?>? columnsOf;
/// <summary>
/// 데이터소스별 컬럼 캐시 — 이 창이 사는 동안 <b>데이터소스마다 한 번만</b> 묻는다.
/// Refresh 는 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다.
/// </summary>
private readonly Dictionary<string, IReadOnlyList<string>?> columnCache = new(StringComparer.Ordinal);
private bool loading = true; private bool loading = true;
/// <summary>연 값의 형태 — 저장할 때 그대로 되돌려 쓴다(원문을 우리가 바꾸지 않는다)</summary>
private DataTableFieldForm form = DataTableFieldForm.Rows;
#endregion #endregion
#region Properties #region Properties
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary> /// <summary>적용 결과 — 취소면 null, '가져오지 않음'이면 빈 문자열</summary>
public string? Result { get; private set; } public string? Result { get; private set; }
#endregion #endregion
#region Constructors #region Constructors
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables) /// <param name="current">지금 저장된 값 — 없으면 새 배선</param>
/// <param name="dataTables">이 서식의 데이터소스 이름</param>
/// <param name="ownerLabel">대상 컨트롤 이름 — 제목에 넣어 "지금 어느 칸 차례인지"를 알린다</param>
/// <param name="columnsOf">데이터소스의 컬럼 이름을 주는 함수(없으면 직접 입력만)</param>
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables,
string? ownerLabel = null, Func<string, IReadOnlyList<string>?>? columnsOf = null)
{ {
InitializeComponent(); InitializeComponent();
tables = dataTables; tables = dataTables;
this.columnsOf = columnsOf;
if (ownerLabel is { Length: > 0 })
{
// 모달이라 인스펙터가 가려진다 — 여러 칸을 연달아 채울 때 어느 칸 차례인지 알 수 없었다
Title = $"값 가져오기 — {ownerLabel}";
}
foreach (var name in tables) foreach (var name in tables)
{ {
@@ -40,16 +84,21 @@ public partial class DataTableFieldDialogView : Window
var spec = DataTableFieldSpec.Parse(current); var spec = DataTableFieldSpec.Parse(current);
if (spec is not null) if (spec is not null)
{ {
FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0; form = spec.Form;
TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal)); TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
FilterBox.Text = spec.Filter; FilterBox.Text = spec.Filter;
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture); RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
FieldBox.Text = spec.Field; FieldBox.Text = spec.Field;
// 기본값이 아닌 것이 접혀 있으면 숨는다 — 그때만 펼친 채로 연다
if (spec.RowIndex != 0 || (form == DataTableFieldForm.Select && spec.Filter.Length > 0))
{
RowExpander.IsExpanded = true;
}
} }
else else
{ {
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다 // 새 배선 — 조건 없이 첫 행. 첫 화면에 평생 안 쓸 개념을 두지 않는다.
FormBox.SelectedIndex = 0; form = DataTableFieldForm.Rows;
RowBox.Text = "0"; RowBox.Text = "0";
if (tables.Count == 1) if (tables.Count == 1)
{ {
@@ -59,47 +108,134 @@ public partial class DataTableFieldDialogView : Window
loading = false; loading = false;
Refresh(); Refresh();
// 첫 포커스 — 데이터소스가 이미 정해졌으면 다음 할 일(컬럼)로 보낸다.
// 이 호출이 없으면 선언 순서상 첫 Tab 정지점이 파괴적 버튼이었다.
Loaded += (_, _) =>
{
if (TableList.SelectedItem is null && TableList.Items.Count > 0)
{
TableList.Focus();
}
else
{
FieldBox.Focus();
}
};
} }
#endregion #endregion
#region Methods #region Methods
/// <summary> /// <summary>
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다 /// 현재 입력을 명세로 옮긴다.
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135). ///
/// <b>행 번호를 조용히 0 으로 바꾸지 않는다.</b> 전에는 파싱 실패가 그대로 0 이 됐는데,
/// 그러면 전각 '1' 을 넣은 배선이 아무 말 없이 첫 행을 가리킨다(실사용에 그런 값이 실재한다).
/// 잘못된 입력은 <see cref="Refresh"/> 가 이유를 말하고 확인을 막는다.
/// </summary> /// </summary>
private DataTableFieldSpec CurrentSpec() private DataTableFieldSpec CurrentSpec() => new()
{ {
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) Form = form,
? parsed TableName = TableList.SelectedItem as string ?? string.Empty,
: 0; Filter = form == DataTableFieldForm.Select ? FilterBox.Text : string.Empty,
return new DataTableFieldSpec RowIndex = ParsedRow() ?? 0,
Field = FieldBox.Text.Trim(),
};
/// <summary>행 번호 — 반각 숫자로 읽히지 않으면 null</summary>
private int? ParsedRow()
{
var text = RowBox.Text.Trim();
if (text.Length == 0)
{ {
Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select, return 0; // 빈 값은 첫 행이다(레거시도 빈 값을 0 으로 본다)
TableName = TableList.SelectedItem as string ?? string.Empty, }
Filter = FilterBox.Text, return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
RowIndex = row,
Field = FieldBox.Text.Trim(),
};
} }
private void Refresh() private void Refresh()
{ {
// 조건 칸은 저장된 값이 조건 형태일 때만 보인다
FilterArea.Visibility = form == DataTableFieldForm.Select ? Visibility.Visible : Visibility.Collapsed;
var spec = CurrentSpec(); var spec = CurrentSpec();
var rowsForm = spec.Form == DataTableFieldForm.Rows; RawText.Text = spec.Compose();
UpdateRowChip(spec);
UpdateFieldHint(spec.TableName);
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된 // 전각 숫자처럼 읽을 수 없는 행 번호는 <b>먼저</b> 말한다 — 조용히 0 으로 바꾸지 않는
FilterBox.IsEnabled = !rowsForm; var problem = ParsedRow() is null
FilterLabel.Opacity = rowsForm ? 0.45 : 1; ? "몇 번째 행: 반각 숫자만 됩니다"
FilterBox.Opacity = rowsForm ? 0.45 : 1; // 고를 데이터소스가 아예 없으면 '고르세요' 는 따를 수 없는 지시다 — 다음 행동을 말한다
: tables.Count == 0
? "이 서식에 데이터소스가 없습니다 — 팔레트에서 먼저 놓고 쿼리를 지정하세요"
: spec.Validate(tables);
PreviewText.Text = spec.Compose(); PlainText.Text = problem is null ? PlainSentence(spec) : "아직 가져올 값이 정해지지 않았습니다.";
var problem = spec.Validate(tables);
ProblemText.Text = problem ?? string.Empty; ProblemText.Text = problem ?? string.Empty;
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible; ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다 // 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
OkButton.IsEnabled = problem is null; OkButton.IsEnabled = problem is null;
} }
/// <summary>사람 말 한 줄 — 이 창이 무엇을 하기로 했는지</summary>
private static string PlainSentence(DataTableFieldSpec spec)
{
var where = spec.RowIndex == 0 ? "첫 행" : $"{spec.RowIndex + 1}번째 행";
var scope = spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0
? "조건을 통과한 행 중 " + where
: where;
return $"서식을 열면 — {spec.TableName} 조회 결과의 {scope}에서 "
+ $"{spec.Field} 값을 가져와 이 칸에 넣습니다.";
}
/// <summary>접힌 ③ 의 머리 칩 — 기본값이 아니면 접혀 있어도 드러낸다</summary>
private void UpdateRowChip(DataTableFieldSpec spec)
{
var parts = new List<string>();
if (spec.RowIndex != 0)
{
parts.Add($"{spec.RowIndex + 1}번째 행");
}
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
{
parts.Add("조건 있음");
}
RowChipText.Text = string.Join(" · ", parts);
RowChip.Visibility = parts.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>
/// 컬럼 칸 아래 안내 — 실제 컬럼 목록을 얻을 수 있으면 그것을 보여 준다.
/// 못 얻으면 <b>왜 못 얻는지</b>를 말한다(전에는 "목록으로 제시하지 않습니다"로 끝났다).
/// </summary>
private void UpdateFieldHint(string tableName)
{
if (tableName.Length == 0)
{
FieldHint.Text = "데이터소스를 먼저 고르면 그 조회 결과의 컬럼 이름을 보여 줍니다.";
return;
}
if (!columnCache.TryGetValue(tableName, out var columns))
{
columns = columnsOf?.Invoke(tableName);
columnCache[tableName] = columns;
}
if (columns is { Count: > 0 })
{
FieldHint.Text = $"{tableName} 의 컬럼 {columns.Count}개 — "
+ string.Join(", ", columns.Take(12))
+ (columns.Count > 12 ? " …" : string.Empty);
return;
}
// 못 읽었을 때도 <b>왜</b>인지 말한다. 전에는 "목록으로 제시하지 않습니다" 로 끝나
// 사용자가 무엇을 하면 되는지 알 수 없었다. 문구는 짧게 — 여기가 도움말 자리다.
FieldHint.Text = "컬럼 목록을 읽지 못했습니다(DB 미접속·쿼리 실패) — 직접 입력하세요."
+ " 조회 결과 표의 머리글 이름이고 대개 전부 대문자입니다(예: ALB, ACPDTE).";
}
private void OnTableChanged(object sender, SelectionChangedEventArgs e) => OnFormChanged(sender, e);
private void OnFormChanged(object sender, RoutedEventArgs e) private void OnFormChanged(object sender, RoutedEventArgs e)
{ {
if (!loading) if (!loading)
@@ -108,8 +244,42 @@ public partial class DataTableFieldDialogView : Window
} }
} }
/// <summary>
/// 조건을 버리고 순서대로 고르기 — <b>되돌릴 수 없어</b> 버려지는 조건을 먼저 보여 준다.
///
/// 전에는 형태 콤보를 바꾸는 것만으로 조건이 조립에서 조용히 사라졌고(Rows 조립은 Filter 를
/// 아예 쓰지 않는다) 다시 열면 Rows 해석에 조건 항목이 없어 영구히 없어졌다.
/// 화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다.
/// </summary>
private void OnDropFilter(object sender, RoutedEventArgs e)
{
var filter = FilterBox.Text;
if (filter.Length > 0 && !Services.DialogService.Confirm("조건 버리기",
"이 배선의 행 조건을 지우고 순서대로 고르게 바꿉니다.",
$"지워지는 조건: {filter}\n확인을 누르면 되돌릴 수 없습니다.",
destructive: true, owner: this))
{
return;
}
form = DataTableFieldForm.Rows;
FilterBox.Text = string.Empty;
Refresh();
}
private void OnCopyRaw(object sender, RoutedEventArgs e)
{
if (RawText.Text is { Length: > 0 } raw)
{
Clipboard.SetText(raw);
}
}
private void OnApply(object sender, RoutedEventArgs e) private void OnApply(object sender, RoutedEventArgs e)
{ {
if (ParsedRow() is null)
{
return;
}
var spec = CurrentSpec(); var spec = CurrentSpec();
if (spec.Validate(tables) is not null) if (spec.Validate(tables) is not null)
{ {
@@ -26,6 +26,20 @@
--> -->
<UserControl.ContextMenu> <UserControl.ContextMenu>
<ContextMenu> <ContextMenu>
<!--
선택 확장이 <b>맨 위</b>다. 아래 정렬·간격 명령이 전제하는 '올바른 선택'을
만드는 수단이고, 그것 없이는 매번 "마퀴로 끌고 딸려온 것 빼기"가 앞에 붙는다.
실측(787건): 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개다.
-->
<MenuItem Header="같은 행 선택" InputGestureText="Alt+1"
Command="{Binding SelectSameCommand}" CommandParameter="Row"/>
<MenuItem Header="같은 열 선택" InputGestureText="Alt+2"
Command="{Binding SelectSameCommand}" CommandParameter="Column"/>
<MenuItem Header="같은 종류 선택" InputGestureText="Alt+3"
Command="{Binding SelectSameCommand}" CommandParameter="Type"/>
<MenuItem Header="같은 크기 선택" InputGestureText="Alt+4"
Command="{Binding SelectSameCommand}" CommandParameter="Size"/>
<Separator/>
<MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A" <MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
Command="{Binding AlignCommand}" CommandParameter="Left"/> Command="{Binding AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D" <MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
@@ -61,6 +75,13 @@
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/> <MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/> <MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
<Separator/> <Separator/>
<!--
'잠그기'로 이름을 한정한다 — 해제는 여기서 못 한다. 히트 판정이 잠긴 것을
후보에서 빼므로 잠긴 컨트롤에는 우클릭조차 닿지 않는다. 해제는 레이어 패널에서.
-->
<MenuItem Header="선택 잠그기" InputGestureText="Ctrl+L" Command="{Binding LockCommand}"/>
<MenuItem Header="선택 숨기기" InputGestureText="Ctrl+Shift+H" Command="{Binding HideCommand}"/>
<Separator/>
<MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/> <MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/>
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/> <MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
</ContextMenu> </ContextMenu>
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420" Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8" <TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/> Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
@@ -23,7 +23,10 @@
<DockPanel Margin="0,10,0,0"> <DockPanel Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/> <Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/> <Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/> <!-- 강조는 <b>고른 것만</b> 바꾸는 쪽이다. '전체에 적용'은 문서 전체를 한 번에 바꾸므로
눈이 먼저 가는 자리에 두지 않는다 — 되돌리기가 있어도 되돌릴 생각을 하려면 먼저 알아채야 한다. -->
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"
Style="{StaticResource Primary}"/>
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/> <TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
</DockPanel> </DockPanel>
</StackPanel> </StackPanel>
+101 -8
View File
@@ -11,6 +11,7 @@
<UserControl.Resources> <UserControl.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:ChoiceLabelConverter x:Key="ChoiceLabel"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/> <BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<!-- <!--
@@ -174,7 +175,8 @@
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다). (자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
--> -->
<Style x:Key="RowLabel" TargetType="TextBlock"> <Style x:Key="RowLabel" TargetType="TextBlock">
<Setter Property="Width" Value="72"/> <!-- 위 주석이 92 를 논증해 놓고 값은 72 였다 — 그래서 '액션 대상 컨…' 처럼 잘렸다 -->
<Setter Property="Width" Value="92"/>
<Setter Property="VerticalAlignment" Value="Center"/> <Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/> <Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
@@ -299,6 +301,18 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/> <Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/> <Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger> </Trigger>
<!--
갈림(다중선택에서 값이 서로 다름) — 표시가 없으면 '꺼짐'과 픽셀 단위로 같다.
그러면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 만들어도
(WPF 는 불확정에서 false 로 간다) 사용자는 무엇이 일어났는지 알 수 없다.
동작 자체는 ToggleRowViewModel.IsOn 이 '갈림에서 누르면 켠다'로 고쳤고,
여기서는 <b>갈렸다는 사실</b>을 보이게 한다. SwitchToggle(:105-108)과 같은 표현.
-->
<Trigger Property="IsChecked" Value="{x:Null}">
<Setter TargetName="bd" Property="Opacity" Value="0.55"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Line2}"/>
</Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
@@ -378,6 +392,38 @@
</Button> </Button>
</DataTemplate> </DataTemplate>
<!--
글꼴 이름 — 고르기도 되고 쓰기도 된다(IsEditable).
목록에 가두면 이 PC 에 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고,
칸을 건드리는 순간 사라진다. 병원 PC 와 설치 글꼴이 다른 것이 정상이다.
UpdateSourceTrigger=LostFocus 인 이유: 커밋 한 번이 문서 전체 딥클론 1회다.
글자마다 커밋하면 "맑은 고딕" 여섯 자에 스냅샷 여섯 개가 쌓인다.
-->
<DataTemplate DataType="{x:Type ins:FontFamilyRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<!-- 이 PC 에 없는 글꼴이면 알린다 — 값은 그대로 두고 '화면이 병원과 다르다'만 말한다 -->
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
ToolTip="{Binding MissingHint}"
Visibility="{Binding IsMissingFont, Converter={StaticResource BoolToVisibility}}"/>
<Grid>
<ComboBox Style="{StaticResource RowCombo}" IsEditable="True" IsTextSearchEnabled="True"
ItemsSource="{Binding Families}"
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}">
<ComboBox.ItemTemplate>
<DataTemplate>
<!-- 이름을 그 글꼴로 그린다 — 이름만으로는 어떤 모양인지 알 수 없다 -->
<TextBlock Text="{Binding}" FontFamily="{Binding}" FontSize="13"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:TextRowViewModel}"> <DataTemplate DataType="{x:Type ins:TextRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
@@ -425,6 +471,15 @@
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}"> <DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
<!--
갈린 값이면 9칸이 전부 꺼져 '아직 아무것도 안 고른 격자'와 똑같이 보인다.
다른 편집기는 MixedHintOverlay 로 알리는데 이 격자만 알릴 자리가 없었다 —
칸 위가 아니라 격자 오른쪽에 붙여 어느 칸도 가리지 않게 한다.
-->
<TextBlock DockPanel.Dock="Right" Text="여러 값" FontStyle="Italic" FontSize="11"
VerticalAlignment="Top" Margin="6,6,0,0"
Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsMixed, Converter={StaticResource BoolToVisibility}}"/>
<ItemsControl ItemsSource="{Binding Cells}"> <ItemsControl ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate> <ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
@@ -533,7 +588,28 @@
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<Grid> <Grid>
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}"/> <!--
빈 항목은 '여러 값' 자리다(ChoiceRowViewModel.EnsureMixedPlaceholder).
그냥 두면 목록에 빈 줄로 보여 무엇인지 알 수 없다.
-->
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ChoiceLabel}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding}" Value="">
<Setter Property="FontStyle" Value="Italic"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/> <TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -547,6 +623,16 @@
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0" <Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택" Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/> Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<!--
카탈로그에 없는 이름 — 글꼴 행과 같은 규칙이다. 값은 건드리지 않고 표식만 낸다.
인라인으로 직접 치는 것이 이 행의 주 경로인데 검증이 하나도 없어서,
한 글자 틀려도 조용히 커밋되고 미리보기에서 <b>빈칸</b>으로만 드러났다 —
빈칸의 원인이 오타인지 자료가 없어서인지 가릴 수 없었다.
-->
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
ToolTip="{Binding UnknownTagHint}"
Visibility="{Binding IsUnknownTag, Converter={StaticResource BoolToVisibility}}"/>
<Grid> <Grid>
<TextBox x:Name="TagInput" Style="{StaticResource RowInput}" <TextBox x:Name="TagInput" Style="{StaticResource RowInput}"
bh:InspectorFieldBehavior.CommitOnEnter="True" bh:InspectorFieldBehavior.CommitOnEnter="True"
@@ -609,8 +695,15 @@
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}"> <DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<!--
이 자리는 값이 아니라 <b>설명</b>이다 — 자르면 결론이 사라진다.
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
지시에 해당하는 뒷부분이 잘려 나가 무엇을 하라는 건지 알 수 없었다.
접어서 다 보여 주고, 그래도 좁으면 툴팁이 받는다.
-->
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}" <TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/> VerticalAlignment="Center" TextWrapping="Wrap"
ToolTip="{Binding ValueText}"/>
</DockPanel> </DockPanel>
</DataTemplate> </DataTemplate>
@@ -821,11 +914,11 @@
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" <ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center"
IsTabStop="False" Focusable="False" IsTabStop="False" Focusable="False"
Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/> Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/>
<Button DockPanel.Dock="Right" Width="24" Height="24" Padding="0" <!--
Style="{StaticResource Subtle}" ToolTip="이 컨트롤에 대한 명령" ⋮ 버튼을 지웠다. Command·Click·ContextMenu 가 <b>전부 없어</b> 눌러도 아무 일이 없었는데,
AutomationProperties.Name="더보기" 툴팁은 '이 컨트롤에 대한 명령' 이라 '단일 선택일 때만 되는 뭔가가 있다'고 오해시켰다.
Visibility="{Binding IsSingleSelection, Converter={StaticResource BoolToVisibility}}" 없는 버튼보다 죽은 버튼이 나쁘다. 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 다 있다.
Content="{Binding Source=ellipsis-vertical, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/> -->
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5" <TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center" Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/> TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
+42 -8
View File
@@ -283,13 +283,41 @@
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}" <ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김" IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/> Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/>
<!--
컨테이너의 자식 수 — 접혀 있어도 무엇이 딸려 있는지 보여야 한다.
페이지 행과 그룹 폴더에는 이미 개수가 붙는데 컨테이너만 없었다.
중첩까지 세므로 "이걸 지우면 몇 개가 같이 지워지나"가 그대로 읽힌다.
-->
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" FontSize="11"
Margin="4,0,2,0" Text="{Binding LayerChildCountText}"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<!-- 선택 밴드 위에서 B.Muted 는 1.03:1 — 개수가 통째로 사라진다 -->
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter Property="Foreground" Value="{DynamicResource B.OnSelMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 --> <!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 -->
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/> <Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 --> <!--
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16" 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다.
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기" <b>없을 때도 16px 칸은 남긴다.</b> 전에는 셰브론이 Collapsed 로 사라져
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}" 폭 16 이 통째로 없어졌는데, 컨테이너 자식 들여쓰기도 정확히 16 이라
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/> <b>부모와 자식의 아이콘이 같은 x 에 놓였다</b> — 목록만 보면 형제로 읽힌다.
계층을 나타내는 유일한 수단인 들여쓰기가 셰브론 폭에 정확히 상쇄되고 있었다.
-->
<Border DockPanel.Dock="Left" Width="16">
<ToggleButton Style="{StaticResource MiniChevron}" Width="16"
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/>
</Border>
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False" <ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/> Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/>
<!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 --> <!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 -->
@@ -321,10 +349,16 @@
</Style> </Style>
</TextBlock.Style> </TextBlock.Style>
</TextBlock> </TextBlock>
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0" <!--
둘째 줄은 <b>항상</b> 있다 — 없으면 목록에 1줄 행과 2줄 행이 섞여
훑을 때 눈이 매번 자리를 다시 잡아야 한다.
타입명을 앞에 두는 이유: 아이콘만으로 안 갈리는 짝이 실제로 있다
(라벨·마스크입력이 같은 글리프, 리스트박스·체크리스트도 같다).
이름(Id)은 표시명과 다를 때만 뒤에 붙는다 — 외부 참조 키라 계속 보여야 한다.
-->
<TextBlock Text="{Binding LayerSubText}" FontSize="10.5" Margin="0,1,0,0"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다" ToolTip="{Binding LayerSubText}">
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}"> <Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/> <Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
@@ -103,6 +103,8 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl
case System.Windows.Input.Key.X when ctrl: designer.CutSelection(); break; case System.Windows.Input.Key.X when ctrl: designer.CutSelection(); break;
case System.Windows.Input.Key.V when ctrl: designer.Paste(); break; case System.Windows.Input.Key.V when ctrl: designer.Paste(); break;
case System.Windows.Input.Key.D when ctrl: designer.CopySelection(); designer.Paste(); break; case System.Windows.Input.Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
// 캔버스와 같은 짝 — 레이어 목록에 포커스가 있을 때도 Ctrl+Shift+Z 는 다시 실행이다
case System.Windows.Input.Key.Z when ctrl && shift: designer.Undo.Redo(); break;
case System.Windows.Input.Key.Z when ctrl: designer.Undo.Undo(); break; case System.Windows.Input.Key.Z when ctrl: designer.Undo.Undo(); break;
case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break; case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break;
case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break; case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break;
@@ -4,18 +4,18 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="타입으로 거르기" Width="380" Height="520" Title="타입으로 거르기" Width="380" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources> <Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:TypeToIconConverter x:Key="TypeToIcon"/> <ctl:TypeToIconConverter x:Key="TypeToIcon"/>
</Window.Resources> </Window.Resources>
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8" <TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/> Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/> <Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/> <Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
+29
View File
@@ -29,6 +29,25 @@
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/> <KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/> <KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/> <KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
<!--
줌 — 커맨드 넷은 처음부터 있었고 <b>키만 없었다</b>. 8pt 글자를 확인하려 200% 로 올렸다가
전체 배치를 보려면 캔버스에서 손을 떼고 화면 맨 아래 가운데 플로팅 바의 배율 버튼을 눌러
팝업을 열고 '화면 맞춤'을 골라야 했다(2단계 + 마우스 왕복). 확대·축소조차 Ctrl+휠뿐이라
트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.
반드시 창 InputBindings 여야 한다 — 캔버스 키 처리기는 포커스가 TextBox/ComboBox 면
통째로 비켜서므로, 인스펙터 값을 고친 직후에는 줌 키가 죽는다.
-->
<KeyBinding Key="D0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
<KeyBinding Key="NumPad0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
<KeyBinding Key="D1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
<KeyBinding Key="NumPad1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
<KeyBinding Key="OemPlus" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
<KeyBinding Key="Add" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
<KeyBinding Key="OemMinus" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
<KeyBinding Key="Subtract" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
<!-- 좌우 패널 접기(Ctrl+\)는 코드비하인드에서 다룬다 — 열 너비를 직접 만지는 일이라
뷰모델이 알 필요가 없다(F4 와 같은 규범) -->
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에 <!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. --> 하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 --> <!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
@@ -599,6 +618,16 @@
</ContentControl> </ContentControl>
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}" <TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/> TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<!--
저장 안 됨 표시. 상태(UndoService.IsDirty)는 저장 지점 깊이까지 추적해
정교하게 있었는데 화면에 한 번도 나오지 않았다 — 서식 셋을 열어 둘을 고치면
어느 것이 안 저장됐는지 알 방법이 없어, 안전하게 전부 다시 저장하게 되고
DB 저장은 그때마다 새 버전을 만든다.
-->
<Ellipse Width="6" Height="6" Margin="6,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource B.Accent}"
ToolTip="저장하지 않은 변경이 있습니다"
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVisibility}}"/>
<Button Content="✕" FontSize="11" Margin="7,0,0,0" <Button Content="✕" FontSize="11" Margin="7,0,0,0"
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기" Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
Command="{Binding DataContext.CloseDocumentCommand, Command="{Binding DataContext.CloseDocumentCommand,
+60 -1
View File
@@ -136,7 +136,11 @@ public partial class MainView : Window
return; return;
} }
var on = PatientSession.HasPatient; var on = PatientSession.HasPatient;
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : null); // 안 고른 상태의 색은 <b>B.Muted</b> 다 — 옆 아이콘들(복사·삭제·테마·저장)이 XAML 컨버터의
// 기본값으로 쓰는 바로 그 키다. 전에는 null 을 넘겼고, 그러면 아이콘이 테마 토큰이 아니라
// 하드코딩 검정으로 칠해져 어두운 제목줄에서 <b>혼자만 까맣게</b> 묻혔다.
// (컴파일러가 CS8604 로 계속 경고하고 있었는데 그 경고가 가리키던 것이 이것이다.)
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : "B.Muted");
PatientIconButton.ToolTip = on PatientIconButton.ToolTip = on
? $"환자: {PatientSession.Label}\n누르면 바꾸기·해제" ? $"환자: {PatientSession.Label}\n누르면 바꾸기·해제"
: "환자 선택 — 태그와 MDataTable 에 실제 값이 들어갑니다(앱 전체 적용)"; : "환자 선택 — 태그와 MDataTable 에 실제 값이 들어갑니다(앱 전체 적용)";
@@ -232,10 +236,24 @@ public partial class MainView : Window
{ {
if (e.Key == System.Windows.Input.Key.F4) if (e.Key == System.Windows.Input.Key.F4)
{ {
// 인스펙터가 접혀 있으면 먼저 편다 — 안 그러면 보이지도 않는 칸으로 포커스가 들어간다
if (panelsCollapsed)
{
TogglePanels();
}
Inspector.FocusFirstRow(); Inspector.FocusFirstRow();
e.Handled = true; e.Handled = true;
return; return;
} }
// Ctrl+\ — 좌우 패널을 함께 접었다 편다('종이만 보기').
// 개별 토글 두 개를 두지 않는다: 목적이 하나라 키도 하나여야 손이 기억한다.
if (e.Key is System.Windows.Input.Key.OemBackslash or System.Windows.Input.Key.Oem5
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
{
TogglePanels();
e.Handled = true;
return;
}
// Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다. // Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다.
// 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다. // 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다.
if (e.Key == System.Windows.Input.Key.O if (e.Key == System.Windows.Input.Key.O
@@ -246,6 +264,47 @@ public partial class MainView : Window
} }
} }
/// <summary>지금 좌우 패널이 접혀 있는가 — 접힌 폭을 '사용자가 정한 폭'으로 오해하지 않기 위한 상태</summary>
private bool panelsCollapsed;
/// <summary>펼쳤을 때의 폭 — 접는 순간 기억해 두었다가 그대로 되돌린다</summary>
private GridLength expandedLeft;
private GridLength expandedRight;
private double expandedLeftMin;
private double expandedRightMin;
/// <summary>
/// 좌우 패널을 함께 접었다 편다 — <b>종이만 보기</b>.
///
/// 좌 248 + 스플리터 5 + 스플리터 5 + 우 300 = 558px 가 <b>항상</b> 점유된다.
/// MinWidth 180/250 때문에 스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
/// 가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없었다.
///
/// <b>MinWidth 도 함께 0 으로 내려야</b> 실제로 사라진다 — Width=0 만으로는 최소폭이 이긴다.
/// 되돌릴 때 쓰려고 펼친 폭을 따로 기억한다(접힌 0 을 사용자가 정한 폭으로 착각하면 안 된다).
/// </summary>
private void TogglePanels()
{
if (panelsCollapsed)
{
LeftColumn.MinWidth = expandedLeftMin;
RightColumn.MinWidth = expandedRightMin;
LeftColumn.Width = expandedLeft;
RightColumn.Width = expandedRight;
panelsCollapsed = false;
return;
}
expandedLeft = LeftColumn.Width;
expandedRight = RightColumn.Width;
expandedLeftMin = LeftColumn.MinWidth;
expandedRightMin = RightColumn.MinWidth;
LeftColumn.MinWidth = 0;
RightColumn.MinWidth = 0;
LeftColumn.Width = new GridLength(0);
RightColumn.Width = new GridLength(0);
panelsCollapsed = true;
}
/// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary> /// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary>
private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList(); private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList();
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="마스크 편집" Width="620" Height="620" Title="마스크 편집" Width="620" Height="620"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<!-- <!--
백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다. 백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다.
아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다. 아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다.
@@ -14,7 +14,7 @@
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="마스크 없음" Padding="12,4" Click="OnClear"/> <Button Content="마스크 없음" Padding="12,4" Click="OnClear"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/> <Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
@@ -4,8 +4,8 @@
Title="환자 선택 — 미리보기" Width="1040" Height="620" Title="환자 선택 — 미리보기" Width="1040" Height="620"
MinWidth="880" MinHeight="480" MinWidth="880" MinHeight="480"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치, <!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치,
성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. --> 성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. -->
@@ -52,13 +52,14 @@
<TextBlock Text="기준 시점" VerticalAlignment="Center" Margin="0,0,6,0" <TextBlock Text="기준 시점" VerticalAlignment="Center" Margin="0,0,6,0"
Foreground="{DynamicResource B.Muted}" Foreground="{DynamicResource B.Muted}"
ToolTip="입원 내원은 구간이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 합니다. 외래는 접수일시로 같습니다."/> ToolTip="입원 내원은 구간이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 합니다. 외래는 접수일시로 같습니다."/>
<ComboBox x:Name="EdgeCombo" Width="92" SelectedIndex="0" <ComboBox x:Name="EdgeCombo" Width="150" SelectedIndex="0"
AutomationProperties.Name="기준 시점"> AutomationProperties.Name="기준 시점">
<ComboBoxItem Content="마지막"/> <ComboBoxItem Content="마지막(퇴원 시점)"/>
<ComboBoxItem Content="처음"/> <ComboBoxItem Content="처음(입원 시점)"/>
</ComboBox> </ComboBox>
<Button x:Name="SelectButton" Content="선택" Padding="20,4" Margin="10,0,0,0" <Button x:Name="SelectButton" Content="선택" Padding="20,4" Margin="10,0,0,0"
Click="OnSelect" IsDefault="True" IsEnabled="False"/> Click="OnSelect" IsDefault="True" IsEnabled="False"
Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -131,12 +132,16 @@
AutomationProperties.Name="내원 목록"> AutomationProperties.Name="내원 목록">
<ListView.View> <ListView.View>
<GridView> <GridView>
<GridViewColumn Header="구분" Width="52" DisplayMemberBinding="{Binding Kind}"/> <GridViewColumn Header="구분" Width="46" DisplayMemberBinding="{Binding Kind}"/>
<GridViewColumn Header="접수일시" Width="118" DisplayMemberBinding="{Binding AcceptedAtText}"/> <GridViewColumn Header="접수일시" Width="106" DisplayMemberBinding="{Binding AcceptedAtText}"/>
<GridViewColumn Header="진료과" Width="100" DisplayMemberBinding="{Binding Department}"/> <!-- 퇴원일시 — 이미 조회돼 있는데 화면에 없었다.
<GridViewColumn Header="담당의" Width="80" DisplayMemberBinding="{Binding Doctor}"/> 입원 내원이 '구간'이라는 사실이 이 열 하나로 눈에 보인다.
<GridViewColumn Header="보험" Width="86" DisplayMemberBinding="{Binding Insurance}"/> 그래야 아래 기준 시점 선택이 무슨 뜻인지 알 수 있다. -->
<GridViewColumn Header="상태" Width="66" DisplayMemberBinding="{Binding State}"/> <GridViewColumn Header="퇴원일시" Width="106" DisplayMemberBinding="{Binding LeftAtText}"/>
<GridViewColumn Header="진료과" Width="86" DisplayMemberBinding="{Binding Department}"/>
<GridViewColumn Header="담당의" Width="68" DisplayMemberBinding="{Binding Doctor}"/>
<GridViewColumn Header="보험" Width="74" DisplayMemberBinding="{Binding Insurance}"/>
<GridViewColumn Header="상태" Width="58" DisplayMemberBinding="{Binding State}"/>
</GridView> </GridView>
</ListView.View> </ListView.View>
</ListView> </ListView>
@@ -86,6 +86,15 @@ public partial class PatientPickerDialogView : Window
} }
} }
/// <summary>검색 유형의 사람이 읽는 이름 — 0건 안내가 "무엇으로 찾았는지" 말할 때 쓴다</summary>
private static string KindLabel(PatientSearchKind kind) => kind switch
{
PatientSearchKind.Name => "성명 접두일치",
PatientSearchKind.ResidentNumber => "주민번호 접두일치",
PatientSearchKind.MobilePhone => "휴대전화 부분일치",
_ => "차트번호 완전일치",
};
private void OnSearch(object sender, RoutedEventArgs e) private void OnSearch(object sender, RoutedEventArgs e)
{ {
if (store is null) if (store is null)
@@ -111,10 +120,42 @@ public partial class PatientPickerDialogView : Window
Mouse.OverrideCursor = Cursors.Wait; Mouse.OverrideCursor = Cursors.Wait;
SearchButton.IsEnabled = false; SearchButton.IsEnabled = false;
var found = store.Search(kind, TermBox.Text); var found = store.Search(kind, TermBox.Text);
var padded = string.Empty;
// 차트번호는 <b>완전일치</b>다. 병원마다 자릿수가 정해져 있어 실제 값은 0으로 채워져 있는데
// (P_PatInf 가 고정 폭이다) 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
// 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데 이 호출부가 그걸 안 지켰다.
//
// <b>자릿수를 설정으로 정하지 않는다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
// 대신 0건일 때만 한 번 더 찾는다 — 8·10자리는 레거시가 쓰던 두 폭이다.
// 찾았으면 무엇으로 찾았는지 칸에 되써서 사용자가 그 번호를 기억하게 한다.
if (found.Count == 0 && kind == PatientSearchKind.ChartNumber
&& TermBox.Text.Trim() is { Length: > 0 } typed && typed.All(char.IsDigit))
{
foreach (var width in new[] { 8, 10 })
{
if (typed.Length >= width)
{
continue;
}
var retry = store.Search(kind, typed.PadLeft(width, '0'));
if (retry.Count > 0)
{
found = retry;
padded = typed.PadLeft(width, '0');
TermBox.Text = padded;
break;
}
}
}
PatientList.ItemsSource = found; PatientList.ItemsSource = found;
PatientCountText.Text = found.Count switch PatientCountText.Text = found.Count switch
{ {
0 => "환자 — 없습니다", // 0건일 때 <b>무엇으로 찾았는지</b> 말한다 — 유형이 틀려서 0건인 경우가 가장 흔한데
// 전에는 그 힌트가 화면에 하나도 없어 접속이나 권한을 의심하게 됐다
0 => $"환자 — 없습니다({KindLabel(kind)}로 찾았습니다)",
_ when padded.Length > 0 => $"환자 {found.Count}명 — 자릿수를 채워 '{padded}' 로 찾았습니다",
PatientVisitStore.DefaultLimit => PatientVisitStore.DefaultLimit =>
$"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요", $"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요",
_ => $"환자 {found.Count}명", _ => $"환자 {found.Count}명",
+56 -17
View File
@@ -1,54 +1,93 @@
<Window x:Class="SheetMe.Designer.Views.PreviewWindow" <Window x:Class="SheetMe.Designer.Views.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="미리보기" Width="900" Height="1000" Title="미리보기" Width="900" Height="1000"
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}" WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
</Window.Resources>
<DockPanel> <DockPanel>
<ToolBarTray DockPanel.Dock="Top"> <ToolBarTray DockPanel.Dock="Top">
<ToolBar> <ToolBar>
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/> <!--
<Button Content="새로 고침" Padding="10,3" Click="OnRefresh" 아이콘 버튼으로 둔다. 전에는 여덟 개가 전부 글자라 툴바가 글자 벽이었고,
ToolTip="구조 변경은 자동으로 따라오고, 창을 누르면 다시 그립니다"/> 무엇이 명령이고 무엇이 상태인지 구분되지 않았다.
글자로 남기는 것은 <b>값과 상태</b>뿐이다 — 고른 환자 이름과 배율.
이름은 툴팁이 받는다(셸 제목줄의 아이콘 줄과 같은 규범).
-->
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnPrint" ToolTip="인쇄..."
Content="{Binding Source=printer, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnRefresh"
ToolTip="다시 그리기 — 구조 변경은 자동으로 따라오고, 창을 누르면 다시 그립니다"
Content="{Binding Source=rotate-cw, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Separator/> <Separator/>
<!-- <!--
레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를 레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를
그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다. 그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다.
기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다. 기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다.
토글은 눌린 상태가 곧 '인쇄 기준'이라는 뜻이다(툴팁이 양쪽을 설명한다).
--> -->
<CheckBox x:Name="PrintViewToggle" Content="인쇄될 것만" IsChecked="True" <ToggleButton x:Name="PrintViewToggle" IsChecked="True"
VerticalAlignment="Center" Margin="4,0" Style="{StaticResource MiniToggle}" Width="34" Height="28" Padding="0"
Checked="OnViewToggled" Unchecked="OnViewToggled" Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="끄면 화면에 보이는 대로인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/> ToolTip="인쇄될 것만 보기 — 끄면 화면에 보이는 대로(인쇄 출력을 끈 컨트롤도 보입니다)"
Content="{Binding Source=eye, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Separator/> <Separator/>
<!-- <!--
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다. 실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다 고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다. 환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
선택·해제를 <b>한 버튼</b>으로 합쳤다 — 셸 제목줄의 환자 아이콘과 같은 방식이다.
안 골랐으면 바로 선택 창, 이미 골랐으면 바꾸기·해제 메뉴를 띄운다.
늘 보이는 자리에서 '누르면 해제'로 두면 실수로 지운다.
--> -->
<Button x:Name="PatientButton" Content="환자 선택..." Padding="10,3" Click="OnPickPatient" <Button x:Name="PatientButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/> Click="OnPickPatient"
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient" ToolTip="환자 선택 — 실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
<!-- <!--
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다. 데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지, "[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다. 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
--> -->
<Button x:Name="DataSourceButton" Content="데이터소스..." Padding="10,3" Click="OnShowDataSources" <Button x:Name="DataSourceButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
IsEnabled="False" ToolTip="MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"/> Click="OnShowDataSources" IsEnabled="False"
ToolTip="데이터소스 결과 — MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"
Content="{Binding Source=table, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!--
미리보기에서 눌러 본 체크·라디오를 원래대로. 문서는 처음부터 안 건드리므로
되돌릴 것이 없고, 여기서 지우는 것은 "눌러 본 흔적"뿐이다.
-->
<Button x:Name="ResetChecksButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnResetChecks" IsEnabled="False"
ToolTip="누른 것 초기화 — 미리보기에서 눌러 본 체크·라디오를 서식의 기본 상태로 돌립니다"
Content="{Binding Source=undo-2, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!-- 고른 환자는 <b>글자로</b> 남긴다 — 이름은 아이콘으로 대신할 수 없는 값이다 -->
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0" <TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis" MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/> Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<Separator/> <Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/> <Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
Click="OnZoomOut" ToolTip="축소"
Content="{Binding Source=minus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/> <TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
<Button Content="" Padding="8,3" Click="OnZoomIn"/> <Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
Click="OnZoomIn" ToolTip="확대"
Content="{Binding Source=plus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
</ToolBar> </ToolBar>
</ToolBarTray> </ToolBarTray>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"> <!--
종이를 눌러 본다 — 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
체크·라디오를 실제로 눌러 확인한다. 클릭은 미리보기 안에서만 살고 문서를 바꾸지 않는다.
-->
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"
MouseLeftButtonUp="OnPaperClick" MouseMove="OnPaperMouseMove">
<StackPanel.LayoutTransform> <StackPanel.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/> <ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform> </StackPanel.LayoutTransform>
+158 -13
View File
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using SheetMe.Designer.Services; using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
@@ -48,6 +49,12 @@ public partial class PreviewWindow : Window
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary> /// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
private SheetMe.Data.Stores.PatientContext? patient; private SheetMe.Data.Stores.PatientContext? patient;
/// <summary>
/// 이 창에서 눌러 본 체크·라디오 — <b>문서에는 쓰지 않는다</b>.
/// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
/// </summary>
private readonly SheetMe.Core.Catalog.PreviewCheckState checks = new();
#endregion #endregion
#region Constructors #region Constructors
@@ -168,12 +175,15 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray, BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20), Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields), Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields, checks),
Width = page.WidthDip, Width = page.WidthDip,
Height = page.HeightDip, Height = page.HeightDip,
// 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
Tag = page,
}; };
PagesHost.Children.Add(frame); PagesHost.Children.Add(frame);
} }
ResetChecksButton.IsEnabled = checks.HasAny;
Title = designer.Document.Title.Length > 0 Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}" ? $"미리보기 — {designer.Document.Title}"
: "미리보기"; : "미리보기";
@@ -181,6 +191,110 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 종이 위 체크·라디오를 눌렀다 — <b>미리보기 안에서만</b> 상태가 바뀐다.
///
/// <b>왜 이 창에서 클릭이 되어야 하나.</b> 레거시 미리보기는 정적 그림이 아니라 런타임 모드
/// (<c>TK_PREVIEW</c>)라서 실제로 눌러 보며 확인한다. 라디오가 배타로 묶였는지는
/// <b>눌러 보는 것 말고 확인할 방법이 없다</b> — 패널로 묶었다고 믿고 저장했다가
/// 임상 화면에서 두 개가 동시에 켜지는 것이 이 서식에서 가장 흔한 사고다.
///
/// <b>왜 문서를 안 건드리나.</b> 확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은
/// 것만으로 저장할 내용이 생긴다. 그래서 상태는 <see cref="checks"/> 에만 쌓고 렌더에서 덮는다.
///
/// <b>왜 WPF 히트테스트가 아닌가.</b> 종이 비주얼은 템플릿으로 만든 그림이라
/// 컨트롤 하나가 여러 요소(테두리·글자·표시)로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
/// 모델 트리를 좌표로 훑으면 캔버스의 <c>HitTestControl</c> 과 같은 규칙을 그대로 쓸 수 있다.
/// </summary>
private void OnPaperClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (Locate(e) is not { } target)
{
return;
}
checks.Click(target.Hit.Model, target.Siblings.Select(vm => vm.Model).ToList());
BuildPages();
}
/// <summary>누를 수 있는 것 위에서는 손가락 커서 — 안 그러면 클릭이 되는지 알 수 없다</summary>
private void OnPaperMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
=> Cursor = Locate(e) is null
? System.Windows.Input.Cursors.Arrow
: System.Windows.Input.Cursors.Hand;
/// <summary>
/// 마우스 위치의 누를 수 있는 컨트롤과 <b>그 직계 형제들</b> — 없으면 null.
///
/// 형제 목록까지 함께 내는 이유: 라디오 배타는 직계 부모가 가른다
/// (레거시 RadioButton 은 <c>GroupName</c> 이 없다) — 누른 것만 알면 무엇을 꺼야 할지 모른다.
/// </summary>
private (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? Locate(
System.Windows.Input.MouseEventArgs e)
{
var printFilter = PrintViewToggle.IsChecked == true;
foreach (var child in PagesHost.Children)
{
if (child is not Border { Tag: PageViewModel page, Child: { } paper })
{
continue;
}
// 종이(Canvas) 기준 좌표로 받는다 — 테두리 1px 과 줌 배율이 여기서 함께 풀린다
var local = e.GetPosition(paper);
if (local.X < 0 || local.Y < 0 || local.X > page.WidthDip || local.Y > page.HeightDip)
{
continue;
}
return HitInPreview(page.Controls, local, printFilter);
}
return null;
}
/// <summary>
/// 그려진 것 중 좌표 위 최상위 컨트롤 — 컨테이너는 자식 우선.
///
/// 캔버스의 히트테스트와 두 군데가 다르다.
/// ① <c>Locked</c> 를 보지 않는다 — 잠금은 편집 보호일 뿐 런타임에서는 눌린다.
/// ② 그려지지 않은 것(숨김·데이터소스·인쇄 제외)은 뺀다 — 안 보이는 것을 누를 수는 없다.
///
/// 맨 위가 누를 수 없는 것(라벨 등)이면 그대로 아무 일도 없다 — 아래로 흘리지 않는다.
/// WinForms 도 맨 위 컨트롤에만 클릭을 준다.
/// </summary>
private static (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? HitInPreview(
IList<ControlViewModel> controls, Point local, bool printFilter)
{
for (var i = controls.Count - 1; i >= 0; i--)
{
var vm = controls[i];
if (vm.Model.Hidden || vm is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(vm.Model)))
{
continue;
}
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
if (vm is ContainerViewModel container
&& HitInPreview(container.Children, new Point(local.X - vm.X, local.Y - vm.Y), printFilter)
is { } childHit)
{
return childHit;
}
return SheetMe.Core.Catalog.PreviewCheckState.IsClickable(vm.Model)
? (vm, controls.ToList())
: null;
}
return null;
}
/// <summary>눌러 본 것을 전부 원래대로 — 문서는 애초에 안 바뀌었으니 되돌릴 것이 없다</summary>
private void OnResetChecks(object sender, RoutedEventArgs e)
{
checks.Clear();
BuildPages();
}
/// <summary> /// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다. /// 실제 환자를 골라 태그에 값을 넣는다.
/// ///
@@ -189,21 +303,42 @@ public partial class PreviewWindow : Window
/// </summary> /// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e) private void OnPickPatient(object sender, RoutedEventArgs e)
{ {
var picked = PatientPickerDialogView.Pick(this, out var label); // 안 골랐으면 바로 선택 창. 이미 골랐으면 <b>메뉴</b>다 —
if (picked is null) // 늘 보이는 아이콘에서 '누르면 해제'로 두면 실수로 지운다.
// 셸 제목줄의 환자 아이콘과 같은 규범이다(버튼 두 개를 하나로 합쳤다).
if (!PatientSession.HasPatient)
{ {
Pick();
return; return;
} }
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다. var menu = new System.Windows.Controls.ContextMenu
// 여기서 직접 UsePatient 도 부르면 두 번 그리고, 더 나쁘게는 {
// 세션과 창이 어긋나는 경로가 하나 더 생긴다. PlacementTarget = PatientButton,
PatientSession.Set(picked, label); Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
};
var header = new System.Windows.Controls.MenuItem { Header = PatientSession.Label, IsEnabled = false };
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
change.Click += (_, _) => Pick();
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
menu.Items.Add(header);
menu.Items.Add(new System.Windows.Controls.Separator());
menu.Items.Add(change);
menu.Items.Add(clear);
menu.IsOpen = true;
void Pick()
{
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다.
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다.
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
{
PatientSession.Set(picked, label);
}
}
} }
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary> /// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
private void OnClearPatient(object sender, RoutedEventArgs e)
=> PatientSession.Set(null, string.Empty);
private void OnPatientSessionChanged(object? sender, EventArgs e) private void OnPatientSessionChanged(object? sender, EventArgs e)
=> UsePatient(PatientSession.Current, PatientSession.Label); => UsePatient(PatientSession.Current, PatientSession.Label);
@@ -217,6 +352,10 @@ public partial class PreviewWindow : Window
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label) internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{ {
patient = picked; patient = picked;
// 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
// 손으로 누른 값이 그 위에 남아 있으면 <b>새 환자의 실제 값을 가린다</b> —
// 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
checks.Clear();
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고 // 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다. // 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null tags = picked is null
@@ -233,13 +372,19 @@ public partial class PreviewWindow : Window
PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked)); PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap());
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document, fields = new MDataTableRunner(designer.Document,
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod)); picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label; PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null; // 고른 상태는 <b>아이콘 색</b>으로 알린다 — 셸 제목줄과 같다.
// 안 고른 상태의 색은 B.Muted 여야 한다(null 을 넘기면 하드코딩 검정으로 칠해져
// 어두운 툴바에서 혼자 묻힌다 — 제목줄에서 실제로 겪은 결함이다).
PatientButton.Content = Controls.LucideIcons.Icon("user", 16,
picked is null ? "B.Muted" : "B.Accent");
DataSourceButton.IsEnabled = fields.Names.Count > 0; DataSourceButton.IsEnabled = fields.Names.Count > 0;
BuildPages(); BuildPages();
} }
@@ -272,7 +417,7 @@ public partial class PreviewWindow : Window
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다. // 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0 PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title ? designer.Document.Title
: designer.Document.FormId, tags, fields); : designer.Document.FormId, tags, fields, checks);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -4,7 +4,7 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520" Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources> <Window.Resources>
<!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 --> <!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 -->
<ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/> <ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/>
@@ -323,9 +323,33 @@
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8"> <DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="닫기" <Button DockPanel.Dock="Right" Content="닫기"
Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/> Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/>
<!--
무엇을 돌렸는지 보여 준다. 0행이 나왔을 때
'치환이 NULL 이라 조건이 사라진 건지, 이 환자에게 자료가 없는 건지'를
창을 나가지 않고 가르는 유일한 수단이다 — 치환을 마친 문장이 답을 갖고 있다.
-->
<Button x:Name="ShowTrialSqlButton" DockPanel.Dock="Right"
Content="돌린 SQL 보기" Margin="0,0,10,0"
Style="{StaticResource LinkBtn}" Click="OnToggleTrialSql"/>
<TextBlock x:Name="TrialSummary" FontSize="12.5" TextWrapping="Wrap" <TextBlock x:Name="TrialSummary" FontSize="12.5" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</DockPanel> </DockPanel>
<!-- 돌린 SQL 원문 — 읽기 전용. 08c 데이터소스 결과 창과 같은 구성이다 -->
<Border x:Name="TrialSqlPane" DockPanel.Dock="Top" Visibility="Collapsed"
Margin="0,0,0,8" BorderThickness="1"
BorderBrush="{DynamicResource B.Line2}"
Background="{DynamicResource B.Input}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="SQL 복사" VerticalAlignment="Top"
Margin="6,4,6,4" Style="{StaticResource LinkBtn}"
Click="OnCopyTrialSql"/>
<TextBox x:Name="TrialSqlText" IsReadOnly="True" BorderThickness="0"
Background="Transparent" FontFamily="Consolas" FontSize="12"
TextWrapping="Wrap" MaxHeight="120" Padding="8,6"
VerticalScrollBarVisibility="Auto"
AutomationProperties.Name="돌린 SQL"/>
</DockPanel>
</Border>
<!-- 표를 감싸는 테두리 — 표 자체는 테두리를 그리지 않는다(위 스타일) --> <!-- 표를 감싸는 테두리 — 표 자체는 테두리를 그리지 않는다(위 스타일) -->
<Border BorderThickness="1" BorderBrush="{DynamicResource B.Line2}"> <Border BorderThickness="1" BorderBrush="{DynamicResource B.Line2}">
<DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200" <DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200"
@@ -422,8 +446,11 @@
<!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 --> <!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,8"> <Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<!-- KeyDown 필수 — 확인 버튼이 IsDefault 라 이 칸의 Enter 가
'저장하고 닫기'로 새어 나간다(변수를 못 넣은 채 편집이 끝난다) -->
<TextBox x:Name="VariableSearch" Padding="28,4,8,4" <TextBox x:Name="VariableSearch" Padding="28,4,8,4"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged" VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
KeyDown="OnVariableSearchKeyDown"
AutomationProperties.Name="치환 변수 검색" AutomationProperties.Name="치환 변수 검색"
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/> ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False" <TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False"
@@ -41,6 +41,9 @@ public partial class QueryEditorWindow : Window
/// <summary>마지막으로 돌린 SQL — 진단이 "무엇을 돌렸는가"를 확인할 때 쓴다</summary> /// <summary>마지막으로 돌린 SQL — 진단이 "무엇을 돌렸는가"를 확인할 때 쓴다</summary>
private string lastTrialSql = string.Empty; private string lastTrialSql = string.Empty;
/// <summary>검증을 돌린 시점의 SQL 원문 — 그 뒤 고쳤는지 판정한다</summary>
private string trialBaseline = string.Empty;
/// <summary>진단 전용 — 검증 실행이 실제로 만든 SQL</summary> /// <summary>진단 전용 — 검증 실행이 실제로 만든 SQL</summary>
internal string LastTrialSql => lastTrialSql; internal string LastTrialSql => lastTrialSql;
@@ -552,6 +555,8 @@ public partial class QueryEditorWindow : Window
emptyPlaceholder: "NULL"); emptyPlaceholder: "NULL");
var probe = converted.Sql; var probe = converted.Sql;
lastTrialSql = probe; lastTrialSql = probe;
// 치환 <b>전</b> 원문을 기준으로 삼는다 — 사용자가 고치는 것은 이쪽이다
trialBaseline = SqlBox.Text;
Mouse.OverrideCursor = Cursors.Wait; Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result; SheetMe.Data.Stores.QueryTrialResult result;
@@ -570,7 +575,11 @@ public partial class QueryEditorWindow : Window
// 같은 정보를 두 군데 적으면 요약이 두 줄로 늘어 결과 표를 밀어내고, // 같은 정보를 두 군데 적으면 요약이 두 줄로 늘어 결과 표를 밀어내고,
// 화면에 환자 이름이 필요 이상으로 여러 번 남는다. // 화면에 환자 이름이 필요 이상으로 여러 번 남는다.
// 환자가 있을 때 필요한 말은 이미 버튼이 하고 있으므로 여기서는 침묵한다. // 환자가 있을 때 필요한 말은 이미 버튼이 하고 있으므로 여기서는 침묵한다.
var basis = variables is not null ? string.Empty : "변수 NULL — 구문만 검사"; // 치환 대상이 <b>하나도 없는</b> 쿼리에 '변수 NULL' 을 적으면 거짓이다 —
// 치환할 것이 없으니 NULL 로 바뀐 것도 없다. 그런데 그 꼬리표가 늘 붙어 있어서
// 정작 진짜 경고가 붙은 날에도 눈에 안 들어왔다. 늘 뜨는 경고는 경고가 아니다.
var hasSubstitution = converted.Hits.Count > 0;
var basis = variables is null && hasSubstitution ? "변수 NULL — 구문만 검사" : string.Empty;
var notes = new List<string>(); var notes = new List<string>();
// 환자를 안 골랐으면 <b>모든</b> 변수가 Unknown 이다 — 그것을 "아직 옮기지 않았다"고 적으면 // 환자를 안 골랐으면 <b>모든</b> 변수가 Unknown 이다 — 그것을 "아직 옮기지 않았다"고 적으면
// 거짓이 된다(ChtNum 은 옮겨져 있다). 그 사유는 위 basis 줄이 이미 말한다. // 거짓이 된다(ChtNum 은 옮겨져 있다). 그 사유는 위 basis 줄이 이미 말한다.
@@ -606,8 +615,11 @@ public partial class QueryEditorWindow : Window
// 빈 조각은 버린다 — 안 버리면 "성공 · · 경고" 처럼 가운뎃점이 떠 있는다 // 빈 조각은 버린다 — 안 버리면 "성공 · · 경고" 처럼 가운뎃점이 떠 있는다
TrialSummary.Text = string.Join(" · ", TrialSummary.Text = string.Join(" · ",
new[] { head, basis }.Concat(notes).Where(s => s.Length > 0)); new[] { head, basis }.Concat(notes).Where(s => s.Length > 0));
// 0행이어도 표를 띄운다 — <b>컬럼 이름</b>은 이미 알고 있고, 그것이 배선 필드 칸에
// 옮겨 적을 값이다. 표를 접으면 방금 조회한 컬럼명을 다시 알아내려고
// 쿼리를 또 돌려야 한다(0행이 배선 작업에서 드문 상태도 아니다).
TrialGrid.ItemsSource = ToGridRows(result); TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed; TrialGrid.Visibility = result.Columns.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
} }
else else
{ {
@@ -616,6 +628,9 @@ public partial class QueryEditorWindow : Window
new[] { "실패 — " + result.Error, basis }.Concat(notes).Where(s => s.Length > 0)); new[] { "실패 — " + result.Error, basis }.Concat(notes).Where(s => s.Length > 0));
TrialGrid.ItemsSource = null; TrialGrid.ItemsSource = null;
TrialGrid.Visibility = Visibility.Collapsed; TrialGrid.Visibility = Visibility.Collapsed;
// 실패했으면 표가 어차피 없다 — 그 자리에 돌린 문장을 바로 보여 준다.
// 오라클 오류 문구만으로는 어디가 틀렸는지 알 수 없고, 답은 치환된 문장에 있다.
SetTrialSqlVisible(true);
} }
} }
@@ -736,6 +751,67 @@ public partial class QueryEditorWindow : Window
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄"; LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateCaretText(); UpdateCaretText();
UpdateProblems(text); UpdateProblems(text);
RefreshTrialFreshness();
}
/// <summary>
/// 검증 결과가 <b>지금 SQL 의 것인지</b> 표시한다.
///
/// 전에는 검증을 돌린 뒤 SQL 을 고쳐도 초록 요약과 결과 표가 그대로 남았다.
/// "검증했다"고 믿고 저장하는데, 저장되는 것은 <b>검증하지 않은 문장</b>이다.
/// 결과를 지우지는 않는다 — 방금 본 컬럼명이 사라지면 그것대로 불편하다.
/// 흐리게 낮추고 다시 돌리라고 말하는 것으로 충분하다.
/// </summary>
private void RefreshTrialFreshness()
{
if (TrialPane is null || TrialPane.Visibility != Visibility.Visible || trialBaseline.Length == 0)
{
return;
}
var stale = !string.Equals(trialBaseline, SqlBox.Text, StringComparison.Ordinal);
TrialPane.Opacity = stale ? 0.5 : 1.0;
if (stale && !TrialSummary.Text.StartsWith(StaleMark, StringComparison.Ordinal))
{
TrialSummary.Text = StaleMark + TrialSummary.Text;
}
else if (!stale && TrialSummary.Text.StartsWith(StaleMark, StringComparison.Ordinal))
{
TrialSummary.Text = TrialSummary.Text[StaleMark.Length..];
}
}
/// <summary>검증 이후 SQL 이 바뀌었을 때 요약 앞에 붙는 표시</summary>
private const string StaleMark = "(수정됨 — 다시 실행하세요) ";
/// <summary>
/// '돌린 SQL' 접기/펴기 — 치환을 마친 문장을 창 안에서 확인한다.
///
/// 0행이 나왔을 때 <b>치환이 NULL 이라 조건이 사라진 것</b>인지
/// <b>이 환자에게 자료가 없는 것</b>인지 가르는 유일한 수단이다. 그 답은 돌린 문장에 있다.
/// 전에는 그 문장을 필드에 담아 두기만 하고(진단용) 화면에는 내보내지 않았다.
/// </summary>
private void OnToggleTrialSql(object sender, RoutedEventArgs e)
{
var show = TrialSqlPane.Visibility != Visibility.Visible;
SetTrialSqlVisible(show);
}
private void SetTrialSqlVisible(bool show)
{
TrialSqlPane.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
ShowTrialSqlButton.Content = show ? "돌린 SQL 숨기기" : "돌린 SQL 보기";
if (show)
{
TrialSqlText.Text = lastTrialSql;
}
}
private void OnCopyTrialSql(object sender, RoutedEventArgs e)
{
if (lastTrialSql.Length > 0)
{
Clipboard.SetText(lastTrialSql);
}
} }
/// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary> /// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary>
@@ -1047,6 +1123,35 @@ public partial class QueryEditorWindow : Window
#endregion #endregion
#region Methods - Variables #region Methods - Variables
/// <summary>
/// 변수 검색칸의 Enter·↓ — <b>확인 버튼으로 새어 나가는 것을 막는다</b>.
///
/// 확인 버튼이 <c>IsDefault</c> 라, 이 칸에서 Enter 를 누르면 검색이나 삽입이 아니라
/// <b>SQL 이 저장되고 창이 닫혔다</b>. '차트' 를 치고 Enter 를 눌렀을 뿐인데
/// 변수를 하나도 못 넣은 채 편집이 끝난다 — 무엇이 일어났는지도 알기 어렵다.
///
/// Enter 는 지금 걸러진 목록의 선택 항목을 넣는다(필터가 하나로 좁혀졌을 때가 대부분이다).
/// ↓ 는 목록으로 이동한다 — 손을 마우스로 옮기지 않고 훑을 수 있어야 한다.
/// </summary>
private void OnVariableSearchKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
InsertSelectedVariable();
e.Handled = true; // IsDefault 버튼에 도달시키지 않는다
return;
}
if (e.Key == Key.Down && VariableList.Items.Count > 0)
{
VariableList.Focus();
if (VariableList.SelectedIndex < 0)
{
VariableList.SelectedIndex = 0;
}
e.Handled = true;
}
}
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e) private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
{ {
SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed; SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="신규 서식 등록" Width="420" SizeToContent="Height" Title="신규 서식 등록" Width="420" SizeToContent="Height"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<StackPanel Margin="16"> <StackPanel Margin="16">
<TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다." <TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다."
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/> TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/>
@@ -26,7 +26,7 @@
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/> TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True"/> <Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -39,6 +39,49 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!--
잠긴 컨트롤 표시 — 편집 크롬이므로 종이(PageView)에는 없다.
그쪽에 넣으면 db-render md5 가 깨진다(레거시와의 픽셀 동일성을 잠그는 게이트다).
숨김은 0.25 투명으로 티가 나는데 잠금은 아무 표시가 없어,
안 잡히는 이유를 화면에서 알 방법이 없었다.
-->
<ItemsControl ItemsSource="{Binding LockMarks}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Rectangle Width="{Binding W}" Height="{Binding H}"
Stroke="#8C8C8C" StrokeThickness="1" StrokeDashArray="2 2"
Fill="Transparent" Opacity="0.7"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
끄는 동안의 치수·좌표 — 편집 크롬이라 종이에는 없다.
줌이 걸린 층이라 역배율로 되돌려 글자 크기를 화면 고정으로 만든다
(간격 수치 칩이 이미 같은 방식을 쓴다).
-->
<Border Canvas.Left="{Binding DragBadgeX}" Canvas.Top="{Binding DragBadgeY}"
Background="{DynamicResource B.AccentFill}" CornerRadius="3" Padding="5,1"
RenderTransformOrigin="0,0"
Visibility="{Binding ShowDragBadge, Converter={StaticResource BoolToVisibility}}">
<Border.RenderTransform>
<ScaleTransform ScaleX="{Binding InverseZoom}" ScaleY="{Binding InverseZoom}"/>
</Border.RenderTransform>
<TextBlock Text="{Binding DragBadgeText}" FontSize="11"
Foreground="{DynamicResource B.OnAccent}"/>
</Border>
<!-- 마퀴(러버밴드) --> <!-- 마퀴(러버밴드) -->
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}" <Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
Width="{Binding MarW}" Height="{Binding MarH}" Width="{Binding MarW}" Height="{Binding MarH}"
@@ -3,14 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380" Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" <TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/> TextWrapping="Wrap" Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/> <TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True" <Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True" Style="{StaticResource Primary}"
ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/> ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/>
<Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/> <Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
@@ -4,12 +4,13 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="태그 선택" Width="1080" Height="700" MinWidth="900" MinHeight="560" Title="태그 선택" Width="1080" Height="700" MinWidth="900" MinHeight="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<!-- <!--
창 껍데기는 OS 크롬을 유지한다. 목업의 '오른쪽 위 X + 둥근 카드'는 WindowStyle=None 이 필요한데, 창 껍데기는 ThemedDialogWindow 가 준다 — 오른쪽 위 X 를 단 자체 타이틀바다.
이 앱의 대화상자 10종이 전부 OS 크롬이라 이 창만 바꾸면 크롬 규약이 두 벌이 된다. (예전 주석은 "이 앱의 대화상자 10종이 전부 OS 크롬이라 이 창만 바꾸면 규약이 두 벌이 된다"였다.
그 뒤 열한 창을 <b>전부</b> 자체 크롬으로 옮겼으므로 그 전제는 더 이상 사실이 아니다.)
AllowsTransparency 는 ClearType 이 꺼져 13px 한글이 뭉개져서 쓰지 않는다(MessageDialogView 주석 참조). AllowsTransparency 는 ClearType 이 꺼져 13px 한글이 뭉개져서 쓰지 않는다(MessageDialogView 주석 참조).
둥근 모서리와 그림자는 DWM 이 이미 그린다. 그림자는 DWM 이 그린다.
--> -->
<Window.Resources> <Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
@@ -64,6 +64,9 @@ public partial class TagPickerDialogView : Window
/// </summary> /// </summary>
private readonly IReadOnlyList<string> preferred; private readonly IReadOnlyList<string> preferred;
/// <summary>액션 태그 목록인가 — 값 칸의 뜻이 데이터 태그와 완전히 다르다</summary>
private readonly bool isActionKind;
private const string FilterAll = "*"; private const string FilterAll = "*";
private const string FilterPreferred = "!"; private const string FilterPreferred = "!";
private const string FilterUsed = "+"; private const string FilterUsed = "+";
@@ -81,11 +84,16 @@ public partial class TagPickerDialogView : Window
#endregion #endregion
#region Constructors #region Constructors
/// <param name="isActionTag">
/// 액션 태그 목록인가 — 액션은 <b>값이 아니라 동작</b>이라 값·사유 칸의 뜻이 완전히 다르다.
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 오른쪽 설명이 거짓이 된다.
/// </param>
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue, public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue,
IReadOnlyList<string>? preferredTags = null) IReadOnlyList<string>? preferredTags = null, bool isActionTag = false)
{ {
allTags = tags; allTags = tags;
preferred = preferredTags ?? Array.Empty<string>(); preferred = preferredTags ?? Array.Empty<string>();
isActionKind = isActionTag;
previewStore = new SheetMe.Data.Stores.TagPreviewStore( previewStore = new SheetMe.Data.Stores.TagPreviewStore(
Services.ConfigService.Current.ConnectionString); Services.ConfigService.Current.ConnectionString);
InitializeComponent(); InitializeComponent();
@@ -295,13 +303,21 @@ public partial class TagPickerDialogView : Window
/// </summary> /// </summary>
private void ShowTagValue(string tag) private void ShowTagValue(string tag)
{ {
if (previewStore is null || !previewStore.CanUseDb)
{
InfoValueBlock.Visibility = Visibility.Collapsed;
return;
}
InfoValueBlock.Visibility = Visibility.Visible; InfoValueBlock.Visibility = Visibility.Visible;
// 액션 태그는 <b>값이 아니다</b>. 더블클릭·버튼이 실행하는 동작이라
// "환자 정보가 있어야 값이 나옵니다" 같은 데이터 태그용 사유를 붙이면
// 149종 전부에서 이 칸이 통째로 거짓말을 한다.
if (isActionKind)
{
InfoValue.Text = "값이 아니라 동작입니다";
InfoValueNote.Text = "더블클릭·버튼에서 실행됩니다 — 종이에 찍히는 값이 아닙니다.";
return;
}
// 값을 <b>만들 수 없는 이유</b>는 DB 없이도 안다(정적 판정이다).
// 전에는 DB 가드가 이 위에 있어서, 접속이 없으면 사유까지 통째로 감춰졌다 —
// 정작 그 사유가 가장 필요한 상황(접속 안 된 단말에서 서식을 훑을 때)에 안 보였다.
if (!TagPreviewCatalog.CanResolve(tag)) if (!TagPreviewCatalog.CanResolve(tag))
{ {
InfoValue.Text = "값을 만들 수 없습니다"; InfoValue.Text = "값을 만들 수 없습니다";
@@ -309,12 +325,29 @@ public partial class TagPickerDialogView : Window
return; return;
} }
// 여기서부터는 실제 값이 필요하다 — 그때만 DB 를 따진다.
// (진단 스크린샷은 DB 없이 이 창을 띄우므로 이 가드가 없으면 늘 "값을 읽지 못했습니다"가 찍힌다)
if (previewStore is null || !previewStore.CanUseDb)
{
InfoValue.Text = "값은 접속된 단말에서 확인됩니다";
InfoValueNote.Text = TagPreviewCatalog.SourceText(tag);
return;
}
previewContext ??= previewStore.Read(UserSession.Current.UidCod, UserSession.Current.UidNam); previewContext ??= previewStore.Read(UserSession.Current.UidCod, UserSession.Current.UidNam);
var context = previewContext.Value; var context = previewContext.Value;
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
? TagPreviewCatalog.FromClock(tag, context.Minute12) ? TagPreviewCatalog.FromClock(tag, context.Minute12)
// <b>본판(10-인자)으로 부른다.</b> 5-인자 위임판은 면허·전문의·직종·부서·부서전화를
// 빈 문자열로 채운다 — 그래서 의사면허번호·전문의번호·직종·부서·부서전화 계열이
// 피커에서는 "이 계정에는 값이 비어 있습니다"로 보이는데 종이에는 실제 값이 찍혔다.
// 값이 나오는 사용자 태그 중 다섯 갈래가 <b>피커에서만</b> 거짓말을 하고 있었다.
// 이 값들은 로그인 세션이 이미 들고 있다(HisUser 15컬럼) — 조회를 더 하지 않는다.
: TagPreviewCatalog.FromUser(tag, context.UidCod, context.UidNam, : TagPreviewCatalog.FromUser(tag, context.UidCod, context.UidNam,
context.MobilePhone, context.OfficePhone); context.MobilePhone, context.OfficePhone,
UserSession.Current.LicenseNumber, UserSession.Current.SpecialistNumber,
UserSession.Current.JobName, UserSession.Current.DepNam,
UserSession.Current.DepPhone);
if (value.Length > 0) if (value.Length > 0)
{ {
+5
View File
@@ -11,5 +11,10 @@
"Designer": { "Designer": {
"GridSize": 4, "GridSize": 4,
"SnapThreshold": 6 "SnapThreshold": 6
},
"Images": {
"//": "싸인·직인 이미지를 파일서버 공유(UNC)에서 직접 읽기 위한 경로 대응. DB 는 서버측 절대경로(예: D:\\MsystechHIS\\002\\...)를 들고 있고, 레거시는 그 경로를 ActiveX 전송 데몬으로 받아 온다 — 우리는 같은 파일을 공유로 읽는다. 병원마다 접두가 다르므로 설치 시 채운다. 진단 --db-filecfg 가 현재 상태와 필요한 설정을 알려 준다.",
"//예시": "\"SignatureShareMap\": [ \"D:\\\\MsystechHIS=\\\\\\\\192.168.100.222\\\\MsystechHIS\" ] 또는 한 줄로 \"D:\\\\MsystechHIS=\\\\\\\\서버\\\\공유;E:\\\\msys=\\\\\\\\서버\\\\msys\"",
"SignatureShareMap": []
} }
} }
@@ -0,0 +1,237 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 여러 컨트롤을 함께 고쳤을 때 <b>레거시가 못 여는 XML</b>이 나오지 않는지 고정한다.
///
/// 여기서 막는 것들은 전부 화면으로 확인할 수 없는 손상이다 —
/// 디자이너는 멀쩡해 보이고, EMR 에서만 다르게 나오거나 서식이 아예 안 열린다.
/// </summary>
[TestClass]
public class BatchEditPolicyTests
{
#region Helpers
private static ControlElement Element(string type, string id = "c")
=> new() { Id = id, Type = type };
private static IReadOnlyList<string> KeysOf(IReadOnlyList<string> types)
=> BatchEditPolicy.MergeCommonDefs(types).Select(d => d.Key).ToList();
#endregion
#region Tests -
[TestMethod]
public void _하나면_그_타입의_속성을_그대로_준다()
{
var label = ControlRegistry.Find("Label");
Assert.IsNotNull(label);
CollectionAssert.AreEqual(
label.Properties.Select(d => d.Key).ToList(),
KeysOf(new[] { "Label" }).ToList());
}
[TestMethod]
public void _선택지가_달라_공통에_오지_않는다()
{
// 라벨은 ContentAlignment 9값(TopLeft…), 텍스트박스는 HorizontalAlignment 3값(Left/Center/Right).
// 키 이름만 비교하는 구현으로 되돌아가면 "MiddleCenter" 가 텍스트박스에 들어가고,
// 그 값은 레거시가 읽지 못한다.
CollectionAssert.DoesNotContain(KeysOf(new[] { "Label", "TextBox" }).ToList(), "TextAlign");
}
[TestMethod]
public void _키는_대소문자로_갈린다()
{
// 라벨만 소문자 visible 이다(레거시가 그림자 속성을 쓴다). 별칭으로 묶으면
// 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
var mixed = KeysOf(new[] { "Label", "TextBox" }).ToList();
CollectionAssert.DoesNotContain(mixed, "visible");
CollectionAssert.DoesNotContain(mixed, "Visible");
}
[TestMethod]
public void _모르는_타입이_섞이면_공통이_없다()
{
// 무엇이 공통인지 말할 근거가 없다 — 추측해서 열면 그게 손상 경로가 된다
Assert.AreEqual(0, KeysOf(new[] { "Label", "Spread" }).Count);
}
[TestMethod]
public void _정의를_가진_공통_속성은_병합된다()
{
// 공통 7종은 `=> new()` 식 본문 프로퍼티라 접근할 때마다 새 인스턴스다.
// def 참조를 비교하는 구현이면 전부 '다르다'가 나와 이 검사가 깨진다.
CollectionAssert.Contains(KeysOf(new[] { "Label", "TextBox" }).ToList(), "PrintOutPut");
}
[TestMethod]
public void _없으면_빈_집합이다()
{
Assert.AreEqual(0, BatchEditPolicy.MergeCommonDefs(Array.Empty<string>()).Count);
}
[TestMethod]
public void _못_올린_것의_개수를_센다()
{
// 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다 — 개수를 알려 줘야 한다
Assert.IsTrue(BatchEditPolicy.ExcludedCount(new[] { "Label", "TextBox" }) > 0);
Assert.AreEqual(0, BatchEditPolicy.ExcludedCount(new[] { "Label" }));
}
#endregion
#region Tests -
[TestMethod]
public void _하나면_아무것도_막지_않는다()
{
// 이 기능이 기존 단일 선택 동작을 좁히면 안 된다
var one = new[] { Element("Label") };
foreach (var key in new[] { "Name", "SignatureIndex", "Formula", "SequenceCode", "TextAlign" })
{
Assert.IsTrue(BatchEditPolicy.IsBatchable(key, one).Allowed, key);
}
}
[TestMethod]
public void _하는_키는_함께_못_바꾼다()
{
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "Name", "SignatureIndex", "DisplaySequence", "TabIndex", "LinkToNewDesign" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _이름으로_가리키는_배선은_함께_못_바꾼다()
{
var two = new[] { Element("RadioButton", "a"), Element("RadioButton", "b") };
foreach (var key in new[] { "Formula", "DataActionTagControl", "AssociatedControl", "EventHandlerMappingTag" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
// 액션 태그 <b>자체</b>는 정상 작업이다 — 같은 문항의 라디오에 같은 액션이 붙는다
Assert.IsTrue(BatchEditPolicy.IsBatchable("DataActionTag", two).Allowed);
}
[TestMethod]
public void _발급은_함께_못_바꾼다()
{
// 서식을 여는 것만으로 DB 카운터가 오른다 — Undo 로 덮이지 않는 유일한 부류다
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "SequenceCode", "SequenceType", "SequenceNumber", "CheckSequenceAtSaving" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _타입이_섞였을_때만_막는다()
{
var sameType = new[] { Element("TextBox", "a"), Element("TextBox", "b") };
var mixedType = new[] { Element("Label", "a"), Element("TextBox", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("TextAlign", sameType).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("TextAlign", mixedType).Allowed);
}
[TestMethod]
public void _방향은_원문_경로에서만_막는다()
{
// 전용 편집기는 컨트롤마다 크기까지 함께 고치므로(ApplyLineChange) 다중 선택에서도 옳다.
// 원문 텍스트 행은 속성만 써서 XML 은 Vertical 인데 경계는 가로인 선을 만든다.
var lines = new[] { Element("Line", "a"), Element("Line", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Orientation", lines).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("Orientation", lines, viaAdvancedRaw: true).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("BorderWidth", lines, viaAdvancedRaw: true).Allowed);
}
[TestMethod]
public void _묶음의_라디오는_기본_선택을_함께_못_바꾼다()
{
// 레거시 라디오는 GroupName 이 없어 직계 부모가 문항이다. 둘 다 켜면 한 문항의 답이 둘이 되고,
// 로더가 부모에 붙이기 전에 값을 대입하므로 열 때 정리되지도 않는다.
var parent = Element("Panel", "p");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var targets = new[] { first, second };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _다른_라디오는_함께_바꿀_수_있다()
{
var left = Element("Panel", "p1");
var right = Element("Panel", "p2");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var allowed = BatchEditPolicy.IsBatchable("Checked", new[] { first, second },
parentOf: element => ReferenceEquals(element, first) ? left : right).Allowed;
Assert.IsTrue(allowed);
}
[TestMethod]
public void _배타가_없어_함께_바꿀_수_있다()
{
var parent = Element("Panel", "p");
var targets = new[] { Element("CheckBox", "c1"), Element("CheckBox", "c2") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _모르면_라디오를_막는다()
{
// 모르면 막는 쪽이다 — 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고
// 그것은 화면으로 확인할 수 없다
var targets = new[] { Element("RadioButton", "r1"), Element("RadioButton", "r2") };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets).Allowed);
}
#endregion
#region Tests - ()
[TestMethod]
public void _갖고_전부_문자열일_때만_원문을_연다()
{
var first = Element("Label", "a");
var second = Element("Label", "b");
first.Props.SetText("Tooltip", "가");
second.Props.SetText("Tooltip", "나");
Assert.IsTrue(BatchEditPolicy.AllShareTextShape("Tooltip", new[] { first, second }));
}
[TestMethod]
public void _형태가_다르면_원문을_열지_않는다()
{
// 이미지 원문(BinaryValue)을 문자열로 덮으면 복구할 수 없다.
// GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지가 둘 다 "" 로 접혀
// <b>갈렸다는 표시조차</b> 안 나온다 — 그래서 형태로 판정한다.
var text = Element("PictureBox", "a");
var binary = Element("PictureBox", "b");
text.Props.SetText("Image", string.Empty);
binary.Props.Set("Image", new LegacyPropValue.BinaryValue { Base64 = "AQID" });
// 어느 쪽을 먼저 골랐든 결과가 같아야 한다 — 클릭 순서로 안전성이 갈리면 안 된다
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { text, binary }));
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { binary, text }));
}
[TestMethod]
public void _가진_키는_원문을_열지_않는다()
{
// 없던 대상에 키가 생기면 레거시 서식생성기가 열 때마다 오류 모달을 띄운다
var has = Element("Label", "a");
var lacks = Element("Label", "b");
has.Props.SetText("Score", "5");
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Score", new[] { has, lacks }));
Assert.AreEqual(1, BatchEditPolicy.CountHaving("Score", new[] { has, lacks }));
}
#endregion
}
@@ -0,0 +1,164 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 미리보기 클릭 — 라디오 배타가 <b>직계 부모 안에서만</b> 일어나는지가 핵심이다.
/// 레거시 RadioButton 은 GroupName 이 없어 부모 컨테이너가 문항 단위이고,
/// 운영 서식의 라디오 98.96% 가 그 형태다.
/// </summary>
[TestClass]
public class PreviewCheckStateTests
{
#region Helpers
private static ControlElement Radio(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "RadioButton" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
private static ControlElement Check(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "CheckBox" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
#endregion
#region Tests
[TestMethod]
public void _않았으면_서식의_기본값을_그대로_읽는다()
{
var state = new PreviewCheckState();
Assert.IsTrue(state.IsChecked(Radio("a", checkedOn: true)));
Assert.IsFalse(state.IsChecked(Radio("b")));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void _누르면_같은_부모의_다른_라디오가_꺼진다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var third = Radio("c");
var siblings = new[] { first, second, third };
var state = new PreviewCheckState();
state.Click(second, siblings);
Assert.IsFalse(state.IsChecked(first));
Assert.IsTrue(state.IsChecked(second));
Assert.IsFalse(state.IsChecked(third));
}
[TestMethod]
public void _부모의_라디오는_영향을_받지_않는다()
{
// 패널 1개 = 문항 1개. 옆 문항의 답이 지워지면 그 서식은 못 쓴다.
var mine = Radio("a");
var neighbour = Radio("z", checkedOn: true);
var state = new PreviewCheckState();
state.Click(mine, new[] { mine, Radio("b") });
Assert.IsTrue(state.IsChecked(neighbour));
}
[TestMethod]
public void _다시_눌러도_꺼지지_않는다()
{
// WinForms 라디오는 자기를 눌러 해제하는 동작이 없다 — 끄는 것은 형제를 누르는 것뿐이다.
var radio = Radio("a");
var state = new PreviewCheckState();
state.Click(radio, new[] { radio });
state.Click(radio, new[] { radio });
Assert.IsTrue(state.IsChecked(radio));
}
[TestMethod]
public void _누를_때마다_반전한다()
{
var box = Check("a");
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsTrue(state.IsChecked(box));
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void _형제_라디오를_끄지_않는다()
{
// 한 패널에 체크박스와 라디오가 섞인 서식이 있다(기타 항목 + 자유 입력).
var radio = Radio("r", checkedOn: true);
var box = Check("c");
var state = new PreviewCheckState();
state.Click(box, new[] { radio, box });
Assert.IsTrue(state.IsChecked(radio));
Assert.IsTrue(state.IsChecked(box));
}
[TestMethod]
public void _체크인_체크박스를_누르면_풀린다()
{
var box = Check("a", checkedOn: true);
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void _눌리지_않는다()
{
// 레거시는 PreventEditing=True 면 OnClick 을 무시한다 — 값 대입만 가능하다.
var locked = Radio("a");
locked.Props.SetText("PreventEditing", "True");
var mate = Radio("b", checkedOn: true);
var state = new PreviewCheckState();
state.Click(locked, new[] { locked, mate });
Assert.IsFalse(PreviewCheckState.IsClickable(locked));
Assert.IsFalse(state.IsChecked(locked));
Assert.IsTrue(state.IsChecked(mate));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void _수_있는_타입은_체크류뿐이다()
{
Assert.IsTrue(PreviewCheckState.IsClickable(Radio("a")));
Assert.IsTrue(PreviewCheckState.IsClickable(Check("b")));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "c", Type = "Label" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "d", Type = "TextBox" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "e", Type = "Panel" }));
}
[TestMethod]
public void _서식의_기본값으로_돌아간다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var state = new PreviewCheckState();
state.Click(second, new[] { first, second });
state.Clear();
Assert.IsFalse(state.HasAny);
Assert.IsTrue(state.IsChecked(first));
Assert.IsFalse(state.IsChecked(second));
}
#endregion
}
@@ -0,0 +1,123 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 싸인 이미지 경로 판정 — 파일서버 전송(ActiveX)을 옮기지 않았으므로
/// "받지 않고 찾는" 규칙이 값의 유무를 가른다. DB·파일 없이 검증한다.
/// </summary>
[TestClass]
public sealed class SignatureImagePathTests
{
#region Methods
private const string Install = @"C:\MsystechHIS_Ver.2";
private const string Remote = @"C:\Sign\Server\hong.tif";
private static readonly string Cached = @"C:\MsystechHIS_Ver.2\Tmp\DownLoadedFiles\Signature\hong.tif";
[TestMethod]
public void _받아둔_캐시가_있으면_그것을_쓴다()
{
var result = SignatureImagePath.Resolve(Remote, Install, p => p == Cached);
Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin);
Assert.AreEqual(Cached, result.LocalPath);
Assert.AreEqual("hong.tif", result.FileName);
}
[TestMethod]
public void _없으면_DB_경로_자체를_본다()
{
var result = SignatureImagePath.Resolve(Remote, Install, p => p == Remote);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
Assert.AreEqual(Remote, result.LocalPath);
}
[TestMethod]
public void _원격보다_먼저다()
{
// 둘 다 있으면 캐시 — 레거시가 그 폴더에 받아 두고 그 경로를 돌려준다
var result = SignatureImagePath.Resolve(Remote, Install, _ => true);
Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin);
}
[TestMethod]
public void _다_없으면_없음이고_파일명은_남는다()
{
// 파일명은 사유 문구에 쓴다 — "무엇을 못 찾았는지" 를 말할 수 있어야 한다
var result = SignatureImagePath.Resolve(Remote, Install, _ => false);
Assert.AreEqual(SignatureImageOrigin.None, result.Origin);
Assert.AreEqual(string.Empty, result.LocalPath);
Assert.AreEqual("hong.tif", result.FileName);
}
[TestMethod]
public void _비면_아무것도_찾지_않는다()
{
var result = SignatureImagePath.Resolve(" ", Install, _ => true);
Assert.AreEqual(SignatureImageOrigin.None, result.Origin);
Assert.AreEqual(string.Empty, result.FileName);
}
[TestMethod]
public void _없으면_캐시를_보지_않는다()
{
// 레거시는 설치 경로가 없으면 "\Tmp\..." 라는 루트 상대경로를 만든다 — 그 결함은 안 옮긴다
Assert.AreEqual(string.Empty, SignatureImagePath.CacheFolder(null));
var result = SignatureImagePath.Resolve(Remote, null, p => p == Remote);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
}
[TestMethod]
public void _매핑이_서버경로를_UNC_로_바꾼다()
{
var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
SignatureImagePath.MapToShare(@"D:\MsystechHIS\002\a.tif", map));
// 대소문자 무시 — 병원 데이터의 표기가 제각각이다
Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
SignatureImagePath.MapToShare(@"d:\msystechhis\002\a.tif", map));
// 대응 없는 접두는 빈 문자열(엉뚱한 경로를 만들지 않는다)
Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"E:\other\a.tif", map));
}
[TestMethod]
public void _겹치면_긴_쪽이_이긴다()
{
// 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다
var map = new[] { @"D:\HIS=\\fs\all", @"D:\HIS\Sign=\\fs\sign" };
Assert.AreEqual(@"\\fs\sign\a.tif", SignatureImagePath.MapToShare(@"D:\HIS\Sign\a.tif", map));
Assert.AreEqual(@"\\fs\all\Etc\b.tif", SignatureImagePath.MapToShare(@"D:\HIS\Etc\b.tif", map));
}
[TestMethod]
public void _오타는_값으로_바꾸지_않는다()
{
// '=' 가 없거나 한쪽이 비면 그 항목은 무시한다 — 반쪽 경로를 만들면 조용히 틀린다
var map = new[] { "그냥문자열", @"D:\HIS=", @"=\\fs\x" };
Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"D:\HIS\a.tif", map));
}
[TestMethod]
public void _없으면_공유를_먼저_보고_직접경로는_그_다음이다()
{
var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
var remote = @"D:\MsystechHIS\002\a.tif";
var shared = @"\\fs\MsystechHIS\002\a.tif";
// 공유에 있으면 공유
var viaShare = SignatureImagePath.Resolve(remote, Install, p => p == shared, map);
Assert.AreEqual(SignatureImageOrigin.Share, viaShare.Origin);
Assert.AreEqual(shared, viaShare.LocalPath);
// 공유에 없고 원격 경로가 열리면 직접
var viaDirect = SignatureImagePath.Resolve(remote, Install, p => p == remote, map);
Assert.AreEqual(SignatureImageOrigin.DirectPath, viaDirect.Origin);
}
[TestMethod]
public void UNC_공유_경로도_파일명을_뗀다()
{
var unc = @"\\fileserver\sign\kim.jpg";
var result = SignatureImagePath.Resolve(unc, Install, p => p == unc);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
Assert.AreEqual("kim.jpg", result.FileName);
}
#endregion
}
@@ -48,10 +48,13 @@ public sealed class TagPreviewCatalogTests
} }
[TestMethod] [TestMethod]
public void _경로_태그는_따로_가른() public void _경로_태그도_환자_문맥이()
{ {
Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인")); // 2026-08-20: 싸인·직인·로고를 이식하면서 분류를 옮겼다.
Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ETC_병원직인")); // 값은 '이미지 파일 경로'이고 환자 해석기가 그것을 얻어 PictureBox 가 그린다 —
// Image 로 두면 "글자만 보여 준다"는 틀린 사유가 화면에 나간다.
Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인"));
Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ETC_병원직인"));
} }
[TestMethod] [TestMethod]