12차 F-1 - 하단 탭에 메모장
사용자가 정한 것: 메모 + 파일 열기 둘 다, 저장 단추 없이 계속 남는다,
이름은 "메모 1 · 메모 2" 번호만, 편집기는 줄 번호 · 글자 크기 · 줄바꿈 + 찾기/바꾸기.
무엇을 만들었나
- Services/Memos.cs: Memo(한 장) · MemoBook(번호 · 이름 · 넘기기 · 닫기, 계산만) ·
MemoStore(보관, {설정 폴더}\Memo\)
- ViewModels/MemoViewModel.cs: 새 메모 · 파일 열기 · 저장 · 다른 이름으로 · 닫기 ·
자동 보관(2초) · 켤 때 되살리기
- Views/MemoView.xaml(.cs): 동작 한 줄 · 메모 탭 줄 · 찾기/바꾸기 줄 · 줄 번호 편집기 · 상태 줄
- AppSetup: 하단 탭 맨 뒤에 넣었다 - 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다
정한 것
- 보관 자리는 {설정 폴더}\Memo\ - E-1 의 Session\ 과 따로. 저쪽은 "저장하면 사라지는 것",
메모는 "지우기 전까지 남는 것"이라 수명이 다르다. 모양(목록 한 장 + 글마다 파일)은 같다
- 이름 없는 메모는 번호만. 첫 줄을 따오면 글을 고칠 때마다 탭 줄이 요동친다.
번호는 비어 있는 가장 작은 수 - 메모 1 을 닫고 새로 만들면 다시 메모 1
- 파일로 연 메모와 이름 없는 메모를 한 탭 줄에 섞는다(notepad++ 와 같다)
- 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추 닫기는 12차 E-2 문서 탭과 같은 모양 -
두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다
- 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정) -
저장하고 닫기 · 저장하지 않고 닫기 · 취소. 고친 것이 없으면 묻지 않는다.
저장이 막히면(취소 · 쓰기 실패) 닫지 않는다
- 메모는 문서 탭과 달리 글을 스스로 들고 있다 - 메모는 원래 작은 글이고,
이름 없는 메모에는 다시 읽어 올 파일 자체가 없다
화면을 보고 고친 세 가지(사용자 보고)
- 다른 탭에 갔다 오면 메모 글이 비어 보였다. 탭을 오가면 화면이 다시 만들어지는데
그때 편집기가 잠깐 비어 있고, 그 틈에 글을 거두면 빈 글로 메모를 덮어썼다.
편집기가 지금 어느 장을 싣고 있는지를 두고, 맞지 않으면 거두지 않게 했다
- 줄 번호와 글이 어긋났다. TextBlock 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록 벌어졌다.
편집기와 같은 종류(TextBox)로 바꾸고 글꼴 · 크기 · 여백을 같은 값으로 두니 같은 자로 줄을 잰다
- 글이 칸 한가운데에서 시작했다. 플랫폼 입력 칸 스타일이 글 칸을 세로 가운데에 붙박아 둔다 -
한 줄짜리 칸을 겨냥한 값이다. VerticalContentAlignment 로는 안 풀려 그 칸을 직접 늘렸다
확인
- 하네스 MemoTest 69건 새로 만듦. 전체 29벌 1,934건 · 실패 0. 빌드 경고 0 · 오류 0
- 화면 30건: 세 장 만들어 각각 쓰기 · 글 안 섞임 · 강제 종료 뒤 세 장이 차례 · 글 그대로 ·
Ctrl+F 찾기 · 모두 바꾸기 3곳 · 닫을 때 세 갈래로 묻고 취소하면 그대로 · 빈 메모는 안 물음 ·
40줄에서 줄 번호 40개가 글과 맞음 · 다른 탭 멀쩡
- 화면 여덟 벌(E-1 · E-2 · D-1 · 줄 번호 · 줄 밀림 · F-1) 모두 통과
시험 도구도 고쳤다: 스크립트가 변수 이름을 $keep 로 써서 라이브러리의 $Keep(되돌릴 설정 목록)을
덮어쓰는 바람에 시험이 끝나도 설정이 안 돌아왔다. 묻는 창을 찾는 도구도 단추 이름을
"저장하지 않고 계속" 로 못 박아 두어 말이 다른 창을 못 알아봤다 - 앞부분만 맞춰 보게 했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b28db92515
commit
9bd27a1960
@ -285,6 +285,65 @@ BASE 칸 자신은 견줄 것이 없어 `4줄 · 기준` 이라고만 적는다.
|
|||||||
실제로 켜서 잰 하단 탭 줄이 **111px · 항목 109px**(배율 150% 기준 72.7 DIP)로 "후" 값과 맞았다.
|
실제로 켜서 잰 하단 탭 줄이 **111px · 항목 109px**(배율 150% 기준 72.7 DIP)로 "후" 값과 맞았다.
|
||||||
앞서 적었던 "지금 배포 폴더에는 예전 것이 그대로" 는 그 뒤 빌드로 낡은 글이 되어 고쳤다.
|
앞서 적었던 "지금 배포 폴더에는 예전 것이 그대로" 는 그 뒤 빌드로 낡은 글이 되어 고쳤다.
|
||||||
|
|
||||||
|
## F-1. 하단 탭에 메모장 자리 (2026-09-28, 완료)
|
||||||
|
|
||||||
|
사용자가 정한 것 — **메모 + 파일 열기 둘 다**, **저장 단추 없이 계속 남는다**,
|
||||||
|
이름은 **`메모 1` · `메모 2` 번호만**, 편집기는 줄 번호 · 글자 크기 · 줄바꿈 **+ 찾기/바꾸기**.
|
||||||
|
|
||||||
|
### 무엇을 만들었나
|
||||||
|
|
||||||
|
- `Services/Memos.cs` — `Memo`(한 장) · `MemoBook`(번호 · 이름 · 넘기기 · 닫기, 계산만) ·
|
||||||
|
`MemoStore`(보관, `{설정 폴더}\Memo\`).
|
||||||
|
- `ViewModels/MemoViewModel.cs` — 새 메모 · 파일 열기 · 저장 · 다른 이름으로 · 닫기 · 자동 보관 · 되살리기.
|
||||||
|
- `Views/MemoView.xaml(.cs)` — 동작 한 줄 · 메모 탭 줄 · 찾기/바꾸기 줄 · 줄 번호 편집기 · 아래 상태 줄.
|
||||||
|
- `AppSetup` — 하단 탭 **맨 뒤**에 넣었다. 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다.
|
||||||
|
|
||||||
|
문서 탭(12차 E-2)과 **같은 모양**을 쓴다 — 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추로 닫기.
|
||||||
|
두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다.
|
||||||
|
|
||||||
|
메모는 문서 탭과 달리 **글을 스스로 들고 있다** — 메모는 원래 작은 글이고, 이름 없는 메모에는
|
||||||
|
다시 읽어 올 파일 자체가 없다.
|
||||||
|
|
||||||
|
### 하네스
|
||||||
|
|
||||||
|
`MemoTest` **69건** 통과 — 번호 매기기(빈 번호 다시 쓰기) · 파일 이름 겹칠 때 폴더 덧붙이기 ·
|
||||||
|
같은 파일 두 장 금지 · 이름 붙이기 · 넘기기 · 닫기 자리 · 떠 있는 장 표시 ·
|
||||||
|
글로 옮기고 되읽기(빈칸 · 한글 · UNC) · 파일로 쓰고 되읽기 · 옛 글 파일 치우기 · 되살려 담기.
|
||||||
|
|
||||||
|
### 화면으로 확인 (값)
|
||||||
|
|
||||||
|
| 확인 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 세 장 만들어 각각 쓰기 | `메모 1` `메모 2` `메모 3` · 가운뎃점 3개 · 글이 섞이지 않음 |
|
||||||
|
| 손 멈춘 뒤 | 보관본 `01.txt` `02.txt` `03.txt` `목록.txt` |
|
||||||
|
| **강제 종료 → 다시 켜기** | **세 장이 차례 · 글 그대로**(저장 단추를 누른 적이 없다) |
|
||||||
|
| 찾기/바꾸기 | Ctrl+F 로 펴짐 · `사과`→`포도` 모두 바꾸기 3곳 |
|
||||||
|
| 닫기 | 고친 메모는 **세 갈래로 물음** · [취소] 면 그대로 3장 · [저장하지 않고 닫기] 면 2장 · **빈 메모는 묻지 않음** |
|
||||||
|
| 줄 번호 | 40줄에서 번호 40개, 글과 한 줄씩 맞음 |
|
||||||
|
| 다른 탭 | 폴더 비교 · 파일 비교 그대로 · 탭을 오가도 메모 글 40줄 그대로 |
|
||||||
|
|
||||||
|
하네스 29벌 **1,934건** 통과 · 실패 0, 화면 여덟 벌 모두 통과(빌드 경고 0 · 오류 0).
|
||||||
|
|
||||||
|
### 화면을 보고 고친 세 가지 (사용자 보고)
|
||||||
|
|
||||||
|
**(가) 다른 탭에 갔다 오면 메모 글이 비어 보였다.** 탭을 오가면 화면이 다시 만들어지는데 그때 편집기가
|
||||||
|
잠깐 비어 있다. 그 틈에 글을 거두면 빈 글로 메모를 덮어썼다. 편집기가 **지금 어느 장을 싣고 있는지**(`_shown`)를
|
||||||
|
두고, 맞지 않으면 거두지 않게 했다.
|
||||||
|
|
||||||
|
**(나) 줄 번호와 글이 어긋났다.** 줄 번호를 `TextBlock` 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록
|
||||||
|
벌어졌다. **편집기와 같은 종류(TextBox)** 로 바꾸고 글꼴 · 크기 · 여백을 같은 값으로 두니 같은 자로 줄을 잰다.
|
||||||
|
|
||||||
|
**(다) 글이 칸 한가운데에서 시작했다.** 플랫폼 입력 칸 스타일이 글 칸(PART_ContentHost)을 세로 가운데에
|
||||||
|
붙박아 둔다 — 한 줄짜리 칸을 겨냥한 값이다. `VerticalContentAlignment` 로는 안 풀려(템플릿 안에 박힌 값)
|
||||||
|
그 칸을 직접 늘려 **맨 위부터** 쓰게 했다.
|
||||||
|
|
||||||
|
### 시험 도구
|
||||||
|
|
||||||
|
시험 스크립트가 변수 이름을 `$keep` 로 써서 **라이브러리의 `$Keep`(되돌릴 설정 파일 목록)을 덮어쓰고** 있었다 —
|
||||||
|
그래서 시험이 끝나도 설정이 안 돌아왔다(두 번 겪고 나서야 찾았다). 이름을 갈랐다.
|
||||||
|
묻는 창을 찾는 도구도 단추 이름을 `저장하지 않고 계속` 로 못 박아 두어, 말이 다른 창(`저장하지 않고 닫기`)을
|
||||||
|
못 알아봤다 — 앞부분만 맞춰 보게 했다.
|
||||||
|
|
||||||
## D-1. 폴더 비교 위쪽을 한 줄로 · 옵션은 서랍으로 (2026-09-28, 완료)
|
## D-1. 폴더 비교 위쪽을 한 줄로 · 옵션은 서랍으로 (2026-09-28, 완료)
|
||||||
|
|
||||||
### 먼저 재현 (화면, 값)
|
### 먼저 재현 (화면, 값)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
| 날짜 | 항목 | 커밋 |
|
| 날짜 | 항목 | 커밋 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-09-28 | **12차 F-1** — 하단 탭에 **메모장**을 새로 넣었다(사용자 요청). 메모 + 파일 열기 둘 다이고, **저장 단추 없이 계속 남는다** — 보관 자리는 `{설정 폴더}\Memo\` 로 E-1 의 `Session\` 과 따로 둔다(저쪽은 저장하면 사라지는 것, 메모는 지우기 전까지 남는 것이라 수명이 다르다). 이름 없는 메모는 `메모 1` · `메모 2` **번호만**(사용자 결정 — 첫 줄을 따오면 글을 고칠 때마다 탭 줄이 요동친다), 번호는 비어 있는 가장 작은 수를 준다. 파일로 연 메모와 한 탭 줄에 섞고, 탭 줄 · 저장 안 한 가운뎃점 · Ctrl+Tab · 가운데 단추 닫기는 12차 E-2 문서 탭과 **같은 모양**이다. 편집기는 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**(사용자 결정). 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(저장하고 닫기 · 저장하지 않고 닫기 · 취소), 고친 것이 없으면 묻지 않는다. 하단 탭 맨 뒤에 넣어 앞 세 탭의 차례를 건드리지 않았다. 화면을 보고 세 가지를 고쳤다(사용자 보고) — 탭을 오가면 화면이 다시 만들어지는 틈에 빈 편집기를 거둬 글을 덮어쓰던 것, 줄 번호를 TextBlock 으로 그려 줄 높이가 어긋나던 것(편집기와 같은 TextBox 로 바꿈), 플랫폼 입력 칸 스타일이 글 칸을 세로 가운데에 붙박아 글이 한가운데에서 시작하던 것(템플릿 안 값이라 그 칸을 직접 늘림). 하네스 `MemoTest` 69건 새로 만듦 · 전체 29벌 1,934건 · 실패 0, 화면 여덟 벌 통과 | 아래 |
|
||||||
| 2026-09-28 | **12차 D-1** — 폴더 비교 위쪽을 한 줄로. 경로 · 옵션 · 찾기 · 색 범례가 네 줄로 **305px(203 DIP)** 를 먹었고 비교하기 전에도 그대로였다. 위 줄은 `[…] 왼쪽 경로 ⇆ […] 오른쪽 경로 [최근▾] 켠옵션 [비교] [취소] [옵션 ▾]` — 폴더 고르기는 `…` 로 줄이고 **좌우 바꾸기(⇆)** 를 새로 넣었다(사용자 결정). 빠른 비교 · 하위 폴더 포함 · 보기 · 포함 · 제외는 `[옵션 ▾]` 서랍으로 넣되, 서랍에 넣으면 "뭘 켜 뒀더라" 를 알 수 없게 되므로 **기본과 다른 것만** 한 줄에 짧게 적는다(`FolderOptionText`). 찾기는 늘 한 줄을 먹던 것을 **Ctrl+F 로 부를 때만** 펴고(Esc 로 접음), 색 범례는 아래 상태 줄로 내렸다(색은 목록을 보는 동안 필요한 것이다). 결과: **305px → 77px(203 → 51 DIP, −75%)**, 좁은 창 1280 DIP 에서도 한 줄. 되던 것은 하나도 없어지지 않았다 (폴더 고르기 · 최근 · 빠른 비교 · 하위 폴더 · 보기 거르기 · 포함/제외 · 폴더 안 찾기 · 결과 내보내기 · 복사/삭제 · 줄 단위 비교 — 값으로 확인). 화면을 보다 `[옵션 ▾]` 이 플랫폼 기본 ToggleButton 스타일 탓에 스위치 모양으로 그려지던 것과 최근 목록이 넓게 벌어지던 것을 고쳤다. 하네스 `FolderTest` 118 → 131건, 전체 28벌 1,865건 · 실패 0 | 아래 |
|
| 2026-09-28 | **12차 D-1** — 폴더 비교 위쪽을 한 줄로. 경로 · 옵션 · 찾기 · 색 범례가 네 줄로 **305px(203 DIP)** 를 먹었고 비교하기 전에도 그대로였다. 위 줄은 `[…] 왼쪽 경로 ⇆ […] 오른쪽 경로 [최근▾] 켠옵션 [비교] [취소] [옵션 ▾]` — 폴더 고르기는 `…` 로 줄이고 **좌우 바꾸기(⇆)** 를 새로 넣었다(사용자 결정). 빠른 비교 · 하위 폴더 포함 · 보기 · 포함 · 제외는 `[옵션 ▾]` 서랍으로 넣되, 서랍에 넣으면 "뭘 켜 뒀더라" 를 알 수 없게 되므로 **기본과 다른 것만** 한 줄에 짧게 적는다(`FolderOptionText`). 찾기는 늘 한 줄을 먹던 것을 **Ctrl+F 로 부를 때만** 펴고(Esc 로 접음), 색 범례는 아래 상태 줄로 내렸다(색은 목록을 보는 동안 필요한 것이다). 결과: **305px → 77px(203 → 51 DIP, −75%)**, 좁은 창 1280 DIP 에서도 한 줄. 되던 것은 하나도 없어지지 않았다 (폴더 고르기 · 최근 · 빠른 비교 · 하위 폴더 · 보기 거르기 · 포함/제외 · 폴더 안 찾기 · 결과 내보내기 · 복사/삭제 · 줄 단위 비교 — 값으로 확인). 화면을 보다 `[옵션 ▾]` 이 플랫폼 기본 ToggleButton 스타일 탓에 스위치 모양으로 그려지던 것과 최근 목록이 넓게 벌어지던 것을 고쳤다. 하네스 `FolderTest` 118 → 131건, 전체 28벌 1,865건 · 실패 0 | 아래 |
|
||||||
| 2026-09-28 | **12차 E-1** — 저장하지 않은 편집이 껐다 켜도 남는다(notepad++ 식). E-2 가 만든 보관함을 `{설정 폴더}\Session\MergeHub\` 에 그대로 옮겨 적는다 — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글, 편집한 글은 짝마다 따로 파일. JSON 을 쓰지 않은 까닭은 최근 목록과 같다(사람이 고칠 수 있고, 글 안에 무엇이 들었든 본문을 따로 두면 탈이 없다). **원본 글은 담지 않는다** — 파일에서 다시 읽고, 그때 파일의 길이 · 수정 시각만 적어 두었다가 다르면 알린다. 목록은 맨 나중에 쓴다(있다는 것이 곧 글 파일이 다 있다는 뜻). 보관은 글자가 멈춘 뒤 2초에 한 번, 탭을 옮기거나 닫거나 새 짝을 열 때는 그 자리에서 바로, 쓰는 일은 다른 실로 넘긴다. 켤 때는 묻지 않고 되살리되 사용자가 지금 열라고 한 것(충돌 파일 · mergetool · 폴더 비교에서 고른 파일)이 먼저다. 원본이 그 사이 바뀌었으면 되살리고 알린다("파일을 연 시점" 은 새로 읽은 글로 두어 [전체 되돌리기] 가 지금 파일로 간다). 다 저장하면 보관본을 치운다. 화면으로 확인: 두 짝을 고치고 **강제로 끝낸 뒤** 다시 켜면 묻지 않고 탭 2개가 글 그대로 되살아나고, 저장하면 가운뎃점이 빠지고, 다 저장하면 보관본이 사라진다. 하네스 `SessionTest` 65건 새로 만듦 · 전체 28벌 1,852건 · 실패 0. 빠르기는 글자당 31ms 로 4줄짜리와 같았으나, **"친 뒤 화면에 보이기까지" 는 UIA 로 재는 비용이 타이핑보다 커서 값으로 확정하지 못했다**. E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면 시험 도구도 보관 자리를 치우고 시작하도록 고쳤다 | 아래 |
|
| 2026-09-28 | **12차 E-1** — 저장하지 않은 편집이 껐다 켜도 남는다(notepad++ 식). E-2 가 만든 보관함을 `{설정 폴더}\Session\MergeHub\` 에 그대로 옮겨 적는다 — 차례 · 자리는 `목록.txt` 에 탭으로 나눈 글, 편집한 글은 짝마다 따로 파일. JSON 을 쓰지 않은 까닭은 최근 목록과 같다(사람이 고칠 수 있고, 글 안에 무엇이 들었든 본문을 따로 두면 탈이 없다). **원본 글은 담지 않는다** — 파일에서 다시 읽고, 그때 파일의 길이 · 수정 시각만 적어 두었다가 다르면 알린다. 목록은 맨 나중에 쓴다(있다는 것이 곧 글 파일이 다 있다는 뜻). 보관은 글자가 멈춘 뒤 2초에 한 번, 탭을 옮기거나 닫거나 새 짝을 열 때는 그 자리에서 바로, 쓰는 일은 다른 실로 넘긴다. 켤 때는 묻지 않고 되살리되 사용자가 지금 열라고 한 것(충돌 파일 · mergetool · 폴더 비교에서 고른 파일)이 먼저다. 원본이 그 사이 바뀌었으면 되살리고 알린다("파일을 연 시점" 은 새로 읽은 글로 두어 [전체 되돌리기] 가 지금 파일로 간다). 다 저장하면 보관본을 치운다. 화면으로 확인: 두 짝을 고치고 **강제로 끝낸 뒤** 다시 켜면 묻지 않고 탭 2개가 글 그대로 되살아나고, 저장하면 가운뎃점이 빠지고, 다 저장하면 보관본이 사라진다. 하네스 `SessionTest` 65건 새로 만듦 · 전체 28벌 1,852건 · 실패 0. 빠르기는 글자당 31ms 로 4줄짜리와 같았으나, **"친 뒤 화면에 보이기까지" 는 UIA 로 재는 비용이 타이핑보다 커서 값으로 확정하지 못했다**. E-1 이 도니 지난 시험이 남긴 편집이 다음 시험에서 되살아나 화면 시험 도구도 보관 자리를 치우고 시작하도록 고쳤다 | 아래 |
|
||||||
| 2026-09-28 | **`[<]` 단추가 있는 줄만 글이 밀리던 것**(사용자 보고 — 그림으로 짚어 준 것이 이것이다). 12차 B-1 로 넣은 `[<]` 단추가 든 칸의 폭이 `Auto` 여서, 단추가 보이는 줄에서만 칸이 벌어져 뒤 칸이 통째로 오른쪽으로 밀렸다 — BASE 는 맨 앞 칸이라 <b>번호까지</b>, LOCAL 은 번호 뒤 칸이라 <b>코드가</b> 밀렸고 단추가 하나 늘 때마다 더 밀렸다. `Visibility="Collapsed"` 는 자리까지 없애므로 칸 폭이 고정이어야 한다. `GutterBothWaysWidth` 를 두어 두 칸이 같은 자리를 늘 잡아 두고, 두 파일 비교에서는 단추 폭 · Git 모드와 네 칸에서는 0 으로 화면이 갈아 끼운다(`ApplyGutterReserve`). 투명한 편집기가 아래 표시층과 어긋나지 않도록 `EditorGutterMargin` 도 같은 값만큼 민다. 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로 잡아 두는 자리도 `CodeFontMetrics` 한 벌에서 나온다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로. BASE 줄 끝의 `[>]`(11차 F-10)는 왼쪽 가장자리를 밀지 않아 이번에는 `Auto` 로 두었다 | 아래 |
|
| 2026-09-28 | **`[<]` 단추가 있는 줄만 글이 밀리던 것**(사용자 보고 — 그림으로 짚어 준 것이 이것이다). 12차 B-1 로 넣은 `[<]` 단추가 든 칸의 폭이 `Auto` 여서, 단추가 보이는 줄에서만 칸이 벌어져 뒤 칸이 통째로 오른쪽으로 밀렸다 — BASE 는 맨 앞 칸이라 <b>번호까지</b>, LOCAL 은 번호 뒤 칸이라 <b>코드가</b> 밀렸고 단추가 하나 늘 때마다 더 밀렸다. `Visibility="Collapsed"` 는 자리까지 없애므로 칸 폭이 고정이어야 한다. `GutterBothWaysWidth` 를 두어 두 칸이 같은 자리를 늘 잡아 두고, 두 파일 비교에서는 단추 폭 · Git 모드와 네 칸에서는 0 으로 화면이 갈아 끼운다(`ApplyGutterReserve`). 투명한 편집기가 아래 표시층과 어긋나지 않도록 `EditorGutterMargin` 도 같은 값만큼 민다. 글자 크기(11차 F-5)를 바꾸면 단추 폭이 따라 바뀌므로 잡아 두는 자리도 `CodeFontMetrics` 한 벌에서 나온다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로. BASE 줄 끝의 `[>]`(11차 F-10)는 왼쪽 가장자리를 밀지 않아 이번에는 `Auto` 로 두었다 | 아래 |
|
||||||
|
|||||||
@ -135,12 +135,16 @@
|
|||||||
- **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다.
|
- **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다.
|
||||||
- **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다.
|
- **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다.
|
||||||
- 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다).
|
- 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다).
|
||||||
- **정할 것**:
|
- **만들면서 정한 것**:
|
||||||
- 메모 보관 자리(`{설정 폴더}\Memo\` 또는 E-1 의 `Session\` 아래 같이 둘지).
|
- **보관 자리**: `{설정 폴더}\Memo\` — E-1 의 `Session\` 과 **따로**. 저쪽은 "저장하면 사라지는 것",
|
||||||
- 이름 없는 메모의 탭 이름(`메모 1` · 첫 줄 몇 자 · 사용자가 붙이기).
|
메모는 "지우기 전까지 남는 것"이라 수명이 다르다. 모양(목록 한 장 + 글마다 파일)은 같다.
|
||||||
- 파일로 연 메모와 이름 없는 메모를 같은 탭 줄에 섞을지.
|
- **이름 없는 메모의 이름**: `메모 1` · `메모 2` **번호만**(사용자 결정). 첫 줄을 따오면 글을 고칠 때마다
|
||||||
- 어디까지 파일 비교와 같게 할지 — 줄 번호 · 문법 강조 · 찾기/바꾸기 · 글자 크기(Ctrl+휠) · 줄바꿈.
|
탭 줄이 요동친다. 번호는 **비어 있는 가장 작은 수** — 메모 1 을 닫고 새로 만들면 다시 메모 1.
|
||||||
- 메모를 파일 비교로 보내는 길이 필요한지(메모 두 장을 견주기).
|
- **섞기**: 파일로 연 메모와 이름 없는 메모를 **한 탭 줄에** 둔다(notepad++ 와 같다).
|
||||||
|
- **어디까지**: 줄 번호 · 글자 크기(Ctrl+휠) · **찾기/바꾸기**(사용자 결정). 문법 강조는 넣지 않았다.
|
||||||
|
- **닫을 때**: 저장하지 않은 것이 있으면 **묻는다**(사용자 결정 2026-09-28) —
|
||||||
|
`저장하고 닫기` · `저장하지 않고 닫기` · `취소` 세 갈래. 고친 것이 없으면 묻지 않는다.
|
||||||
|
- 메모를 파일 비교로 보내는 길은 **이번에 만들지 않았다**(쓰다 필요하면 그때).
|
||||||
- **판정 기준**:
|
- **판정 기준**:
|
||||||
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
|
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
|
||||||
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.
|
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.
|
||||||
|
|||||||
@ -419,6 +419,12 @@
|
|||||||
| **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) |
|
| **완료** | C-1 트레이 아이콘 — 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 · 우클릭 [열기]/[종료]. 새 플랫폼(`D:\Sorce\ubsrepo`) 에 넣음(11차 뒤 G-2 와 같은 작업) |
|
||||||
| **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 |
|
| **완료** | C-2 탭 줄 크기 줄이기 — 하단 탭 줄 항목을 74 → 61 DIP 로(여백 13/14→9/10 · 아이콘 22→20 · 틈 7→5 · 글자 13.5→12.5). 실측 133 → 109px(−18%), 파일 비교 코드 칸 47 → 48줄. **배포됨** — 새 플랫폼을 빌드하면서 함께 나갔고, 2026-09-28 배포본 해시 · 화면 실측(111px · 항목 109px)으로 확인 |
|
||||||
|
|
||||||
|
### F. 메모장 탭
|
||||||
|
|
||||||
|
| 상태 | 항목 |
|
||||||
|
|---|---|
|
||||||
|
| **완료** | F-1 하단 탭에 메모장 — 메모 + 파일 열기 둘 다, **저장 단추 없이 계속 남는다**(`{설정 폴더}\Memo\`). 이름은 `메모 1` · `메모 2` 번호만(빈 번호를 다시 쓴다), 파일로 연 메모와 한 탭 줄에 섞는다. 탭 줄 · 가운뎃점 · Ctrl+Tab 은 E-2 문서 탭과 같은 모양. 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**. 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정). 하네스 `MemoTest` 69건, 화면 30건(강제 종료 뒤 세 장 되살아남 포함) |
|
||||||
|
|
||||||
### D. 폴더 비교 화면 다시 만들기
|
### D. 폴더 비교 화면 다시 만들기
|
||||||
|
|
||||||
| 상태 | 항목 |
|
| 상태 | 항목 |
|
||||||
|
|||||||
@ -29,6 +29,10 @@ namespace MergeHub
|
|||||||
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
|
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
|
||||||
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
|
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
|
||||||
manager.AddMain<GitView, GitViewModel>(Icon.Link, ViewIds.Git, "Git");
|
manager.AddMain<GitView, GitViewModel>(Icon.Link, ViewIds.Git, "Git");
|
||||||
|
|
||||||
|
// 메모장은 <b>맨 뒤</b>에 둔다(12차 F-1). 앞 세 탭의 차례가 바뀌면 손버릇도 바뀐다 -
|
||||||
|
// 비교하다 잠깐 적어 두는 자리라 흐름의 끝에 있는 편이 맞기도 하다.
|
||||||
|
manager.AddMain<MemoView, MemoViewModel>(Icon.Document, ViewIds.Memo, "메모");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RegisterSettings(ITabManager manager)
|
public void RegisterSettings(ITabManager manager)
|
||||||
@ -52,5 +56,6 @@ namespace MergeHub
|
|||||||
public const string Home = "MergeHub.Home";
|
public const string Home = "MergeHub.Home";
|
||||||
public const string Folder = "MergeHub.Folder";
|
public const string Folder = "MergeHub.Folder";
|
||||||
public const string Git = "MergeHub.Git";
|
public const string Git = "MergeHub.Git";
|
||||||
|
public const string Memo = "MergeHub.Memo";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
439
src/MergeHub/Services/Memos.cs
Normal file
439
src/MergeHub/Services/Memos.cs
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MergeHub.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 메모 한 장(12차 F-1). 이름 없는 메모이거나, 파일로 연 메모다.
|
||||||
|
///
|
||||||
|
/// 문서 탭(12차 E-2)과 달리 <b>글을 스스로 들고 있다</b> - 메모는 원래 작은 글이고,
|
||||||
|
/// 이름 없는 메모에는 다시 읽어 올 파일 자체가 없다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Memo : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
/// <summary>이름 없는 메모의 번호(<c>메모 3</c>). 파일로 연 메모는 0.</summary>
|
||||||
|
public int Number { get; internal set; }
|
||||||
|
|
||||||
|
private string? _path;
|
||||||
|
|
||||||
|
/// <summary>파일로 연 메모의 경로. 이름 없는 메모는 null.</summary>
|
||||||
|
public string? Path
|
||||||
|
{
|
||||||
|
get => _path;
|
||||||
|
internal set { if (Set(ref _path, value)) Raise(nameof(Title)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>파일에 매인 메모인가.</summary>
|
||||||
|
public bool HasFile => !string.IsNullOrWhiteSpace(Path);
|
||||||
|
|
||||||
|
private string _title = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>탭 줄에 그릴 이름. 파일 이름이거나 <c>메모 N</c>(사용자 결정 2026-09-28).</summary>
|
||||||
|
public string Title
|
||||||
|
{
|
||||||
|
get => _title;
|
||||||
|
internal set => Set(ref _title, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _text = string.Empty;
|
||||||
|
|
||||||
|
public string Text
|
||||||
|
{
|
||||||
|
get => _text;
|
||||||
|
set => Set(ref _text, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isDirty;
|
||||||
|
|
||||||
|
/// <summary>저장하지 않은 것이 있는가. 탭 이름 앞 가운뎃점에 쓴다(E-2 와 같은 표시).</summary>
|
||||||
|
public bool IsDirty
|
||||||
|
{
|
||||||
|
get => _isDirty;
|
||||||
|
set => Set(ref _isDirty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isCurrent;
|
||||||
|
|
||||||
|
public bool IsCurrent
|
||||||
|
{
|
||||||
|
get => _isCurrent;
|
||||||
|
internal set => Set(ref _isCurrent, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>캐럿 자리. 메모를 오갈 때 그 자리로 돌아가려고 든다.</summary>
|
||||||
|
public int CaretIndex { get; set; }
|
||||||
|
|
||||||
|
public string Tip => HasFile ? Path! : "이름 없는 메모입니다. 저장하지 않아도 남습니다";
|
||||||
|
|
||||||
|
/// <summary>마지막으로 떠 있던 순서.</summary>
|
||||||
|
internal long Touched { get; set; }
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
Raise(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Raise(string? name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 메모 여러 장(12차 F-1). 계산만 한다 - 파일을 읽고 쓰는 것은 <see cref="MemoStore"/> 가,
|
||||||
|
/// 화면과 잇는 것은 ViewModel 이 한다.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 이름 짓기는 사용자가 정했다 - 이름 없는 메모는 <b>메모 1 · 메모 2</b> 로만(첫 줄을 따오지 않는다).
|
||||||
|
/// 글을 고칠 때마다 이름이 바뀌면 탭 줄이 요동친다.
|
||||||
|
/// 번호는 <b>비어 있는 가장 작은 수</b>를 준다 - 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 파일로 연 메모와 이름 없는 메모는 <b>한 탭 줄에 섞어</b> 둔다(notepad++ 와 같다).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoBook
|
||||||
|
{
|
||||||
|
private readonly List<Memo> _memos = new List<Memo>();
|
||||||
|
private long _clock;
|
||||||
|
|
||||||
|
public IReadOnlyList<Memo> Memos => _memos;
|
||||||
|
|
||||||
|
public int Count => _memos.Count;
|
||||||
|
|
||||||
|
public Memo? Current { get; private set; }
|
||||||
|
|
||||||
|
public int CurrentIndex => Current is null ? -1 : _memos.IndexOf(Current);
|
||||||
|
|
||||||
|
/// <summary>메모가 있어야 편집기를 보여 준다.</summary>
|
||||||
|
public bool Any => _memos.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>새 메모 한 장. 번호는 비어 있는 가장 작은 수.</summary>
|
||||||
|
public Memo Add(string text = "")
|
||||||
|
{
|
||||||
|
var memo = new Memo { Number = FreeNumber(), Text = text ?? string.Empty };
|
||||||
|
_memos.Add(memo);
|
||||||
|
Retitle();
|
||||||
|
Activate(memo);
|
||||||
|
return memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 파일로 연다. 같은 파일이 이미 있으면 새로 만들지 않고 그 장을 띄운다 -
|
||||||
|
/// 같은 파일이 두 장에 있으면 어느 쪽이 맞는지 알 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
public Memo Open(string path, string text)
|
||||||
|
{
|
||||||
|
var found = Find(path);
|
||||||
|
if (found is not null)
|
||||||
|
{
|
||||||
|
Activate(found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
var memo = new Memo { Path = path, Text = text ?? string.Empty };
|
||||||
|
_memos.Add(memo);
|
||||||
|
Retitle();
|
||||||
|
Activate(memo);
|
||||||
|
return memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Memo? Find(string? path) =>
|
||||||
|
string.IsNullOrWhiteSpace(path)
|
||||||
|
? null
|
||||||
|
: _memos.FirstOrDefault(m => string.Equals(m.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>이름 없는 메모에 파일 이름을 붙인다(다른 이름으로 저장).</summary>
|
||||||
|
public void Attach(Memo memo, string path)
|
||||||
|
{
|
||||||
|
if (memo is null || string.IsNullOrWhiteSpace(path)) return;
|
||||||
|
|
||||||
|
memo.Path = path;
|
||||||
|
memo.Number = 0;
|
||||||
|
Retitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Activate(Memo memo)
|
||||||
|
{
|
||||||
|
if (memo is null || !_memos.Contains(memo)) return;
|
||||||
|
Current = memo;
|
||||||
|
memo.Touched = ++_clock;
|
||||||
|
foreach (var other in _memos) other.IsCurrent = ReferenceEquals(other, memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Activate(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= _memos.Count) return;
|
||||||
|
Activate(_memos[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>닫는다. 닫은 자리의 오른쪽으로 옮겨 가고, 오른쪽이 없으면 왼쪽으로(E-2 와 같다).</summary>
|
||||||
|
public Memo? Close(Memo memo)
|
||||||
|
{
|
||||||
|
var at = _memos.IndexOf(memo);
|
||||||
|
if (at < 0) return Current;
|
||||||
|
|
||||||
|
_memos.RemoveAt(at);
|
||||||
|
memo.IsCurrent = false;
|
||||||
|
Retitle();
|
||||||
|
|
||||||
|
if (!ReferenceEquals(Current, memo)) return Current;
|
||||||
|
|
||||||
|
if (_memos.Count == 0) { Current = null; return null; }
|
||||||
|
Activate(_memos[Math.Min(at, _memos.Count - 1)]);
|
||||||
|
return Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Memo? Next() => Step(1);
|
||||||
|
|
||||||
|
public Memo? Previous() => Step(-1);
|
||||||
|
|
||||||
|
private Memo? Step(int by)
|
||||||
|
{
|
||||||
|
if (_memos.Count == 0) return null;
|
||||||
|
var at = CurrentIndex;
|
||||||
|
if (at < 0) { Activate(_memos[0]); return Current; }
|
||||||
|
|
||||||
|
var next = (at + by) % _memos.Count;
|
||||||
|
if (next < 0) next += _memos.Count;
|
||||||
|
Activate(_memos[next]);
|
||||||
|
return Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
foreach (var memo in _memos) memo.IsCurrent = false;
|
||||||
|
_memos.Clear();
|
||||||
|
Current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>되살릴 때 쓴다 - 이름 짓기와 번호를 건드리지 않고 그대로 담는다.</summary>
|
||||||
|
public Memo Adopt(Memo memo)
|
||||||
|
{
|
||||||
|
_memos.Add(memo);
|
||||||
|
Retitle();
|
||||||
|
return memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>비어 있는 가장 작은 번호. 메모 1 을 닫고 새로 만들면 다시 메모 1 이다.</summary>
|
||||||
|
private int FreeNumber()
|
||||||
|
{
|
||||||
|
var used = new HashSet<int>(_memos.Where(m => m.Number > 0).Select(m => m.Number));
|
||||||
|
var n = 1;
|
||||||
|
while (used.Contains(n)) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이름을 다시 짓는다. 파일 이름이 겹치면 갈릴 때까지 위 폴더를 한 겹씩 덧붙인다(E-2 와 같은 규칙).</summary>
|
||||||
|
private void Retitle()
|
||||||
|
{
|
||||||
|
foreach (var memo in _memos)
|
||||||
|
memo.Title = memo.HasFile ? DocTabs.Tail(memo.Path!, 0) : $"메모 {memo.Number}";
|
||||||
|
|
||||||
|
for (var depth = 1; depth <= 6; depth++)
|
||||||
|
{
|
||||||
|
var clashed = _memos.Where(m => m.HasFile)
|
||||||
|
.GroupBy(m => m.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.SelectMany(g => g)
|
||||||
|
.ToList();
|
||||||
|
if (clashed.Count == 0) return;
|
||||||
|
|
||||||
|
var moved = false;
|
||||||
|
foreach (var memo in clashed)
|
||||||
|
{
|
||||||
|
var wider = DocTabs.Tail(memo.Path!, depth);
|
||||||
|
if (string.Equals(wider, memo.Title, StringComparison.Ordinal)) continue;
|
||||||
|
memo.Title = wider;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
if (!moved) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>탭 줄에 그릴 글. 저장하지 않은 것은 앞에 가운뎃점(E-2 와 같은 표시).</summary>
|
||||||
|
public static string Mark(Memo memo) => memo.IsDirty ? "• " + memo.Title : memo.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 메모를 파일로 보관한다(12차 F-1). <b>저장 단추 없이 계속</b> 남는 것이 사용자 결정이라,
|
||||||
|
/// 이름 없는 메모도 여기에 그대로 적힌다.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 자리는 <c>{설정 폴더}\Memo\</c> - 12차 E-1 의 <c>Session\</c> 과 <b>따로</b> 둔다.
|
||||||
|
/// 저쪽은 "저장하지 않은 편집" 이라 저장하면 사라지는 것이고, 메모는 <b>지우기 전까지 남는</b> 것이다.
|
||||||
|
/// 모양은 같다(목록 한 장 + 글마다 파일) - 같은 까닭에서다.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoStore
|
||||||
|
{
|
||||||
|
private const string Header = "# MergeHub 메모 (12차 F-1)";
|
||||||
|
private const int Version = 1;
|
||||||
|
|
||||||
|
public const string ListFileName = "목록.txt";
|
||||||
|
|
||||||
|
private readonly object _gate = new object();
|
||||||
|
|
||||||
|
public MemoStore(string root)
|
||||||
|
{
|
||||||
|
Root = root ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Root { get; }
|
||||||
|
|
||||||
|
public static string DefaultRoot(string configRoot) => Path.Combine(configRoot, "Memo");
|
||||||
|
|
||||||
|
public string ListPath => Path.Combine(Root, ListFileName);
|
||||||
|
|
||||||
|
public static string BodyName(int index) =>
|
||||||
|
string.Format(CultureInfo.InvariantCulture, "{0:00}.txt", index + 1);
|
||||||
|
|
||||||
|
/// <summary>보관한 메모 한 장(글은 따로 파일).</summary>
|
||||||
|
public sealed class Saved
|
||||||
|
{
|
||||||
|
public int Number { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public bool Dirty { get; set; }
|
||||||
|
public int CaretIndex { get; set; }
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Serialize(IReadOnlyList<Saved> memos, int current)
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.AppendLine(Header);
|
||||||
|
text.AppendLine(string.Join("\t", "V",
|
||||||
|
Version.ToString(CultureInfo.InvariantCulture),
|
||||||
|
current.ToString(CultureInfo.InvariantCulture)));
|
||||||
|
|
||||||
|
for (var i = 0; i < memos.Count; i++)
|
||||||
|
{
|
||||||
|
var memo = memos[i];
|
||||||
|
text.AppendLine(string.Join("\t",
|
||||||
|
"M",
|
||||||
|
i.ToString(CultureInfo.InvariantCulture),
|
||||||
|
memo.Number.ToString(CultureInfo.InvariantCulture),
|
||||||
|
memo.Dirty ? "D" : "-",
|
||||||
|
memo.CaretIndex.ToString(CultureInfo.InvariantCulture),
|
||||||
|
string.IsNullOrEmpty(memo.Path) ? "-" : memo.Path!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>알아볼 수 없는 줄은 조용히 건너뛴다. 글은 채우지 않는다(부르는 쪽이 파일로 읽는다).</summary>
|
||||||
|
public static (List<Saved> Memos, int Current) Parse(string? content)
|
||||||
|
{
|
||||||
|
var list = new List<Saved>();
|
||||||
|
var current = -1;
|
||||||
|
if (string.IsNullOrWhiteSpace(content)) return (list, current);
|
||||||
|
|
||||||
|
foreach (var raw in content!.Replace("\r\n", "\n").Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.TrimEnd();
|
||||||
|
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
|
||||||
|
|
||||||
|
var parts = line.Split('\t');
|
||||||
|
|
||||||
|
if (parts[0] == "V")
|
||||||
|
{
|
||||||
|
if (parts.Length >= 3 && int.TryParse(parts[2], out var at)) current = at;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[0] != "M" || parts.Length < 6) continue;
|
||||||
|
|
||||||
|
list.Add(new Saved
|
||||||
|
{
|
||||||
|
Number = Number(parts[2]),
|
||||||
|
Dirty = parts[3] == "D",
|
||||||
|
CaretIndex = Number(parts[4]),
|
||||||
|
Path = parts[5] == "-" ? null : parts[5]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current >= list.Count) current = list.Count - 1;
|
||||||
|
return (list, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Number(string field) =>
|
||||||
|
int.TryParse(field, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : 0;
|
||||||
|
|
||||||
|
public void Save(IReadOnlyList<Saved> memos, int current)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (memos.Count == 0) { ClearCore(); return; }
|
||||||
|
|
||||||
|
if (!Directory.Exists(Root)) Directory.CreateDirectory(Root);
|
||||||
|
|
||||||
|
foreach (var old in Directory.EnumerateFiles(Root, "*.txt"))
|
||||||
|
{
|
||||||
|
if (!string.Equals(Path.GetFileName(old), ListFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
File.Delete(old);
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain = new UTF8Encoding(false);
|
||||||
|
for (var i = 0; i < memos.Count; i++)
|
||||||
|
File.WriteAllText(Path.Combine(Root, BodyName(i)), memos[i].Text ?? string.Empty, plain);
|
||||||
|
|
||||||
|
// 목록은 맨 나중에 - 목록이 있다는 것이 곧 글 파일이 다 있다는 뜻이다.
|
||||||
|
File.WriteAllText(ListPath, Serialize(memos, current), new UTF8Encoding(true));
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (List<Saved> Memos, int Current) Load()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(ListPath)) return (new List<Saved>(), -1);
|
||||||
|
|
||||||
|
var (list, current) = Parse(File.ReadAllText(ListPath, Encoding.UTF8));
|
||||||
|
|
||||||
|
for (var i = 0; i < list.Count; i++)
|
||||||
|
{
|
||||||
|
var body = Path.Combine(Root, BodyName(i));
|
||||||
|
try { list[i].Text = File.Exists(body) ? File.ReadAllText(body, Encoding.UTF8) : string.Empty; }
|
||||||
|
catch (IOException) { list[i].Text = string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (list, current);
|
||||||
|
}
|
||||||
|
catch (IOException) { return (new List<Saved>(), -1); }
|
||||||
|
catch (UnauthorizedAccessException) { return (new List<Saved>(), -1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_gate) { ClearCore(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearCore()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(Root)) return;
|
||||||
|
foreach (var file in Directory.EnumerateFiles(Root, "*.txt")) File.Delete(file);
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
448
src/MergeHub/ViewModels/MemoViewModel.cs
Normal file
448
src/MergeHub/ViewModels/MemoViewModel.cs
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using MergeHub.Services;
|
||||||
|
using UbisamBase.Core.Messaging;
|
||||||
|
|
||||||
|
namespace MergeHub.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 메모장 탭(12차 F-1). 사용자가 정한 것 — <b>메모 + 파일 열기 둘 다</b>, 그리고
|
||||||
|
/// <b>저장 단추 없이 계속 남는다</b>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 탭 줄 · 저장 안 한 표시(가운뎃점) · Ctrl+Tab 은 파일 비교의 문서 탭(12차 E-2)과 <b>같은 모양</b>이다 -
|
||||||
|
/// 두 화면이 서로 다르게 움직이면 배우는 것이 둘이 된다.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 보관 자리는 <c>{설정 폴더}\Memo\</c> — E-1 의 <c>Session\</c> 과 따로 둔다.
|
||||||
|
/// 저쪽은 "저장하면 사라지는 것"이고 메모는 "지우기 전까지 남는 것"이라 수명이 다르다.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class MemoViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly GitWorkspace _workspace;
|
||||||
|
private readonly MemoStore _store;
|
||||||
|
|
||||||
|
/// <summary>글자가 멈춘 뒤에 한 번만 쓴다(E-1 과 같은 2초).</summary>
|
||||||
|
private readonly DispatcherTimer _saveTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
public MemoViewModel(GitWorkspace workspace)
|
||||||
|
{
|
||||||
|
_workspace = workspace;
|
||||||
|
_store = new MemoStore(MemoStore.DefaultRoot(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
|
||||||
|
_saveTimer.Tick += (_, _) => SaveAll();
|
||||||
|
|
||||||
|
Restore();
|
||||||
|
|
||||||
|
// 한 장도 없으면 빈 메모 하나를 띄워 둔다 - 빈 화면에서는 무엇을 해야 할지 알 수 없다.
|
||||||
|
if (!Book.Any) NewMemo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MemoBook Book { get; } = new MemoBook();
|
||||||
|
|
||||||
|
/// <summary>탭 줄에 뿌릴 목록.</summary>
|
||||||
|
public ObservableCollection<Memo> Items { get; } = new ObservableCollection<Memo>();
|
||||||
|
|
||||||
|
/// <summary>탭 줄에서 고른 장. 고르는 것만으로 옮겨 간다.</summary>
|
||||||
|
public Memo? CurrentMemo
|
||||||
|
{
|
||||||
|
get => Book.Current;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null || ReferenceEquals(value, Book.Current)) return;
|
||||||
|
GoTo(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasMemos => Book.Any;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _status = "메모는 저장하지 않아도 남습니다.";
|
||||||
|
|
||||||
|
// ================= 편집기와 잇는 자리 =================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 편집기 글은 화면이 들고 있고 여기서는 필요할 때만 읽어 간다 -
|
||||||
|
/// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
|
||||||
|
/// </summary>
|
||||||
|
public Func<string>? ReadEditorText { get; set; }
|
||||||
|
|
||||||
|
public Func<int>? ReadCaretIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>편집기 글을 통째로 갈아 끼운다(장을 옮길 때).</summary>
|
||||||
|
public event EventHandler<string>? EditorTextReplaced;
|
||||||
|
|
||||||
|
/// <summary>캐럿을 이 자리로 옮겨 달라고 화면에 부탁한다.</summary>
|
||||||
|
public event EventHandler<int>? CaretIndexRequested;
|
||||||
|
|
||||||
|
private bool _loading;
|
||||||
|
|
||||||
|
/// <summary>사용자가 글을 고쳤다고 화면이 알려 준다.</summary>
|
||||||
|
public void NotifyEdited(string text, int caretIndex)
|
||||||
|
{
|
||||||
|
if (_loading || Book.Current is not { } memo) return;
|
||||||
|
if (!ReferenceEquals(_shown, memo)) return; // 화면이 아직 이 장을 싣지 않았다
|
||||||
|
|
||||||
|
memo.Text = text;
|
||||||
|
memo.CaretIndex = caretIndex;
|
||||||
|
memo.IsDirty = true;
|
||||||
|
Touch();
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Touch()
|
||||||
|
{
|
||||||
|
if (_loading) return;
|
||||||
|
_saveTimer.Stop();
|
||||||
|
_saveTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sync()
|
||||||
|
{
|
||||||
|
if (!Items.SequenceEqual(Book.Memos))
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
foreach (var memo in Book.Memos) Items.Add(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentMemo));
|
||||||
|
OnPropertyChanged(nameof(HasMemos));
|
||||||
|
RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseCanExecuteChanged()
|
||||||
|
{
|
||||||
|
SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
SaveAsCommand.NotifyCanExecuteChanged();
|
||||||
|
CloseMemoCommand.NotifyCanExecuteChanged();
|
||||||
|
NextMemoCommand.NotifyCanExecuteChanged();
|
||||||
|
PreviousMemoCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>그 장을 편집기에 올린다.</summary>
|
||||||
|
private void Show(Memo? memo)
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EditorTextReplaced?.Invoke(this, memo?.Text ?? string.Empty);
|
||||||
|
if (memo is not null && memo.CaretIndex > 0)
|
||||||
|
CaretIndexRequested?.Invoke(this, memo.CaretIndex);
|
||||||
|
_shown = memo;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoTo(Memo memo)
|
||||||
|
{
|
||||||
|
// 떠 있던 장의 글을 먼저 거둔다 - 화면이 주인이라 여기 것이 낡았을 수 있다.
|
||||||
|
Harvest();
|
||||||
|
|
||||||
|
Book.Activate(memo);
|
||||||
|
Sync();
|
||||||
|
Show(memo);
|
||||||
|
SaveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 편집기가 지금 <b>어느 장</b>을 싣고 있는지. 화면에서 글을 거둬 올 때 이것이 맞아야 한다.
|
||||||
|
///
|
||||||
|
/// 탭을 옮겨 다니면 화면(MemoView)이 다시 만들어질 수 있는데, 그때 편집기는 잠깐 <b>비어 있다</b>.
|
||||||
|
/// 그 틈에 거두면 빈 글로 메모를 덮어쓴다 - 화면에서 보고 찾았다(2026-09-28).
|
||||||
|
/// </summary>
|
||||||
|
private Memo? _shown;
|
||||||
|
|
||||||
|
/// <summary>화면에 떠 있는 글을 그 장에 옮겨 담는다.</summary>
|
||||||
|
private void Harvest()
|
||||||
|
{
|
||||||
|
if (Book.Current is not { } memo || ReadEditorText is null) return;
|
||||||
|
|
||||||
|
// 편집기가 아직 이 장을 싣지 않았으면 거두지 않는다.
|
||||||
|
if (!ReferenceEquals(_shown, memo)) return;
|
||||||
|
|
||||||
|
memo.Text = ReadEditorText();
|
||||||
|
memo.CaretIndex = ReadCaretIndex?.Invoke() ?? memo.CaretIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 명령 =================
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void NewMemo()
|
||||||
|
{
|
||||||
|
Harvest();
|
||||||
|
var memo = Book.Add();
|
||||||
|
Sync();
|
||||||
|
Show(memo);
|
||||||
|
Status = $"{memo.Title} 을(를) 만들었습니다.";
|
||||||
|
SaveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenFile()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "메모로 열 파일 고르기",
|
||||||
|
Filter = "글 파일 (*.txt;*.md;*.log;*.cs;*.json;*.xml)|*.txt;*.md;*.log;*.cs;*.json;*.xml|모든 파일 (*.*)|*.*"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(dialog.FileName, Encoding.UTF8);
|
||||||
|
Harvest();
|
||||||
|
var memo = Book.Open(dialog.FileName, text);
|
||||||
|
memo.IsDirty = false;
|
||||||
|
Sync();
|
||||||
|
Show(memo);
|
||||||
|
Status = $"{memo.Title} 을(를) 열었습니다.";
|
||||||
|
SaveAll();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
MessageUtil.ShowErrorToast($"파일을 열지 못했습니다: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanSave() => Book.Current is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 파일에 쓴다. 이름이 없는 메모면 <see cref="SaveAs"/> 로 넘어간다.
|
||||||
|
/// <b>메모 자체는 저장하지 않아도 남는다</b> - 이 단추는 "파일로 내보내 두기" 에 가깝다.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
if (Book.Current is { } memo) SaveMemo(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||||
|
private void SaveAs()
|
||||||
|
{
|
||||||
|
if (Book.Current is { } memo) SaveMemoAs(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>파일에 쓴다. 이름이 없으면 어디에 쓸지 묻는다. 썼으면 true.</summary>
|
||||||
|
private bool SaveMemo(Memo memo)
|
||||||
|
{
|
||||||
|
if (!memo.HasFile) return SaveMemoAs(memo);
|
||||||
|
|
||||||
|
if (ReferenceEquals(memo, Book.Current)) Harvest();
|
||||||
|
if (!WriteFile(memo, memo.Path!)) return false;
|
||||||
|
|
||||||
|
memo.IsDirty = false;
|
||||||
|
Status = $"{memo.Title} 에 저장했습니다.";
|
||||||
|
SaveAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>어디에 쓸지 묻고 쓴다. 사용자가 그만두면 false.</summary>
|
||||||
|
private bool SaveMemoAs(Memo memo)
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "메모를 파일로 저장",
|
||||||
|
FileName = memo.HasFile ? Path.GetFileName(memo.Path!) : memo.Title + ".txt",
|
||||||
|
Filter = "글 파일 (*.txt)|*.txt|Markdown (*.md)|*.md|모든 파일 (*.*)|*.*"
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true) return false;
|
||||||
|
|
||||||
|
if (ReferenceEquals(memo, Book.Current)) Harvest();
|
||||||
|
if (!WriteFile(memo, dialog.FileName)) return false;
|
||||||
|
|
||||||
|
Book.Attach(memo, dialog.FileName);
|
||||||
|
memo.IsDirty = false;
|
||||||
|
Sync();
|
||||||
|
Status = $"{memo.Title} 에 저장했습니다.";
|
||||||
|
SaveAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WriteFile(Memo memo, string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 저장하기 전 백업(11차 F-9)은 파일 비교와 같은 자리를 쓴다.
|
||||||
|
_workspace.Backup.Keep(path);
|
||||||
|
File.WriteAllText(path, memo.Text, new UTF8Encoding(true));
|
||||||
|
_workspace.AppendLog($"메모 저장: {path}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanClose() => Book.Current is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 장을 닫는다.
|
||||||
|
///
|
||||||
|
/// <b>저장하지 않은 것이 있으면 묻는다</b>(사용자 결정 2026-09-28) - 닫는 것은 그 메모를 버리는 일이라
|
||||||
|
/// 되돌릴 수 없다. 예/아니오로는 "저장하고 닫기" 와 "그만두기" 를 가를 수 없어 세 갈래로 묻는다.
|
||||||
|
/// 이름 없는 메모에서 [저장하고 닫기] 를 고르면 어디에 쓸지 묻는 창으로 이어진다.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanClose))]
|
||||||
|
private void CloseMemo(Memo? memo)
|
||||||
|
{
|
||||||
|
memo ??= Book.Current;
|
||||||
|
if (memo is null) return;
|
||||||
|
|
||||||
|
// 묻기 전에 화면의 글을 거둔다 - 방금 친 글까지 저장되어야 한다.
|
||||||
|
if (ReferenceEquals(memo, Book.Current)) Harvest();
|
||||||
|
|
||||||
|
if (memo.IsDirty)
|
||||||
|
{
|
||||||
|
var choice = MessageUtil.ShowChoice(
|
||||||
|
"메모 닫기",
|
||||||
|
$"{memo.Title} 에 저장하지 않은 것이 있습니다.\n\n닫으면 이 메모는 사라집니다.",
|
||||||
|
"저장하고 닫기", "저장하지 않고 닫기");
|
||||||
|
|
||||||
|
if (choice == DialogChoice.Cancel) return;
|
||||||
|
|
||||||
|
if (choice == DialogChoice.Primary)
|
||||||
|
{
|
||||||
|
// 저장이 막히면(취소 · 쓰기 실패) 닫지 않는다 - 여기서 넘어가면 그대로 잃는다.
|
||||||
|
if (!SaveMemo(memo)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = memo.Title;
|
||||||
|
var next = Book.Close(memo);
|
||||||
|
Sync();
|
||||||
|
Show(next);
|
||||||
|
Status = $"{name} 을(를) 닫았습니다.";
|
||||||
|
SaveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanStep() => Book.Count > 1;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanStep))]
|
||||||
|
private void NextMemo()
|
||||||
|
{
|
||||||
|
Harvest();
|
||||||
|
var at = Book.CurrentIndex;
|
||||||
|
if (at < 0 || Book.Count < 2) return;
|
||||||
|
GoTo(Book.Memos[(at + 1) % Book.Count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanStep))]
|
||||||
|
private void PreviousMemo()
|
||||||
|
{
|
||||||
|
Harvest();
|
||||||
|
var at = Book.CurrentIndex;
|
||||||
|
if (at < 0 || Book.Count < 2) return;
|
||||||
|
GoTo(Book.Memos[(at - 1 + Book.Count) % Book.Count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ActivateMemo(Memo? memo)
|
||||||
|
{
|
||||||
|
if (memo is not null) GoTo(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 찾기 · 바꾸기 (사용자 결정) =================
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isFindOpen;
|
||||||
|
[ObservableProperty] private string _findText = string.Empty;
|
||||||
|
[ObservableProperty] private string _replaceText = string.Empty;
|
||||||
|
[ObservableProperty] private bool _findMatchCase;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenFind() => IsFindOpen = true;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseFind() => IsFindOpen = false;
|
||||||
|
|
||||||
|
// ================= 보관 =================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 지금 있는 것을 모두 보관한다. 쓰는 일은 다른 실로 넘긴다(E-1 과 같다).
|
||||||
|
/// </summary>
|
||||||
|
private void SaveAll()
|
||||||
|
{
|
||||||
|
_saveTimer.Stop();
|
||||||
|
if (_loading) return;
|
||||||
|
|
||||||
|
Harvest();
|
||||||
|
|
||||||
|
var list = Book.Memos.Select(memo => new MemoStore.Saved
|
||||||
|
{
|
||||||
|
Number = memo.Number,
|
||||||
|
Path = memo.Path,
|
||||||
|
Dirty = memo.IsDirty,
|
||||||
|
CaretIndex = memo.CaretIndex,
|
||||||
|
Text = memo.Text
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var current = Book.CurrentIndex;
|
||||||
|
var store = _store;
|
||||||
|
Task.Run(() => store.Save(list, current));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>켤 때 보관해 둔 메모를 되살린다. 묻지 않는다.</summary>
|
||||||
|
private void Restore()
|
||||||
|
{
|
||||||
|
List<MemoStore.Saved> saved;
|
||||||
|
int current;
|
||||||
|
try { (saved, current) = _store.Load(); }
|
||||||
|
catch (Exception) { return; }
|
||||||
|
|
||||||
|
if (saved.Count == 0) return;
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var one in saved)
|
||||||
|
{
|
||||||
|
var memo = new Memo
|
||||||
|
{
|
||||||
|
Number = one.Number,
|
||||||
|
Path = one.Path,
|
||||||
|
Text = one.Text,
|
||||||
|
IsDirty = one.Dirty,
|
||||||
|
CaretIndex = one.CaretIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일로 연 메모인데 고친 것이 없으면 <b>파일에서 다시 읽는다</b> -
|
||||||
|
// 그 사이 남이 고쳤으면 그쪽이 맞다.
|
||||||
|
if (memo.HasFile && !memo.IsDirty && File.Exists(memo.Path))
|
||||||
|
{
|
||||||
|
try { memo.Text = File.ReadAllText(memo.Path!, Encoding.UTF8); }
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
Book.Adopt(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Book.Activate(current >= 0 ? current : Book.Count - 1);
|
||||||
|
Sync();
|
||||||
|
Status = $"메모 {Book.Count}장을 되살렸습니다.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>화면이 처음 붙을 때 지금 장을 편집기에 올린다.</summary>
|
||||||
|
public void ShowCurrent() => Show(Book.Current);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/MergeHub/Views/MemoView.xaml
Normal file
202
src/MergeHub/Views/MemoView.xaml
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
<UserControl x:Class="MergeHub.Views.MemoView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
|
||||||
|
shell:PlatformChrome.FullBleed="True"
|
||||||
|
PreviewKeyDown="View_PreviewKeyDown">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="/MergeHub;component/Views/Controls/AppStyles.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style x:Key="Muted" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="10">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/> <!-- ① 동작 한 줄 -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- ② 메모 탭 줄 -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- ③ 찾기 · 바꾸기(Ctrl+F 로 펼 때만) -->
|
||||||
|
<RowDefinition Height="*"/> <!-- ④ 편집기 -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- ⑤ 아래 상태 줄 -->
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- ① 동작 -->
|
||||||
|
<Border Grid.Row="0" Padding="6,5" BorderThickness="1" Margin="0,0,0,4"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Style="{StaticResource Muted}" FontSize="11"
|
||||||
|
Text="저장하지 않아도 남습니다 · Ctrl+Tab 넘기기 · Ctrl+F 찾기"/>
|
||||||
|
<Button Content="새 메모" Command="{Binding NewMemoCommand}"
|
||||||
|
Height="26" MinWidth="70" Padding="10,0" FontSize="11" Margin="0,0,4,0"
|
||||||
|
Style="{StaticResource FilledButton}"
|
||||||
|
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
|
||||||
|
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}" FontWeight="Bold"
|
||||||
|
ToolTip="이름 없는 메모를 한 장 더 만듭니다 (Ctrl+N)"/>
|
||||||
|
<Button Content="파일 열기..." Command="{Binding OpenFileCommand}"
|
||||||
|
Height="26" MinWidth="84" Padding="10,0" FontSize="11" Margin="0,0,4,0"
|
||||||
|
ToolTip="파일을 메모로 엽니다 (Ctrl+O)"/>
|
||||||
|
<Button Content="저장" Command="{Binding SaveCommand}"
|
||||||
|
Height="26" MinWidth="60" Padding="10,0" FontSize="11" Margin="0,0,4,0"
|
||||||
|
ToolTip="파일로 씁니다. 이름이 없으면 어디에 쓸지 묻습니다 (Ctrl+S)"/>
|
||||||
|
<Button Content="다른 이름으로..." Command="{Binding SaveAsCommand}"
|
||||||
|
Height="26" MinWidth="104" Padding="10,0" FontSize="11" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="이 메모 닫기" Command="{Binding CloseMemoCommand}"
|
||||||
|
Height="26" MinWidth="86" Padding="10,0" FontSize="11" Margin="0,0,4,0"
|
||||||
|
ToolTip="이 메모를 닫습니다 (Ctrl+W). 저장하지 않은 것이 있으면 물어봅니다"/>
|
||||||
|
<TextBlock/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ② 메모 탭 줄. 파일 비교의 문서 탭(12차 E-2)과 같은 모양이다. -->
|
||||||
|
<ListBox Grid.Row="1" x:Name="MemoTabs"
|
||||||
|
ItemsSource="{Binding Items}"
|
||||||
|
SelectedItem="{Binding CurrentMemo, Mode=TwoWay}"
|
||||||
|
Margin="0,0,0,4" Padding="0" BorderThickness="0" Background="Transparent"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Disabled">
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="Margin" Value="0"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border x:Name="TabShell" Margin="0,0,3,0" CornerRadius="3,3,0,0" BorderThickness="1,1,1,0"
|
||||||
|
Background="{DynamicResource Ubisam.Brush.Input.Background}"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||||
|
MouseDown="MemoTab_MouseDown"
|
||||||
|
ToolTip="{Binding Tip}">
|
||||||
|
<StackPanel Orientation="Horizontal" Height="24">
|
||||||
|
<TextBlock Text="•" Width="10" FontSize="13" FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" TextAlignment="Center" Margin="4,0,0,0"
|
||||||
|
Foreground="{DynamicResource Ubisam.Brush.Accent}"
|
||||||
|
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="11" VerticalAlignment="Center"
|
||||||
|
Margin="2,0,2,0" MaxWidth="240" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<Button Content="×" FontSize="12" Width="18" Height="18" Padding="0" Margin="2,0,3,0"
|
||||||
|
VerticalAlignment="Center" Focusable="False"
|
||||||
|
Command="{Binding DataContext.CloseMemoCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip="이 메모를 닫습니다. 저장하지 않은 것이 있으면 물어봅니다"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<DataTemplate.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||||
|
<Setter TargetName="TabShell" Property="Background"
|
||||||
|
Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
|
||||||
|
<Setter TargetName="TabShell" Property="BorderBrush"
|
||||||
|
Value="{DynamicResource Ubisam.Brush.Accent}"/>
|
||||||
|
<Setter TargetName="TabShell" Property="BorderThickness" Value="1,2,1,0"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</DataTemplate.Triggers>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<!-- ③ 찾기 · 바꾸기 -->
|
||||||
|
<Border Grid.Row="2" Padding="8,5" BorderThickness="1" Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding IsFindOpen, Converter={StaticResource BoolToVis}}"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="찾기" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,6,0"/>
|
||||||
|
<TextBox x:Name="FindBox" Text="{Binding FindText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="200" Height="26" FontFamily="Consolas" FontSize="11"
|
||||||
|
VerticalContentAlignment="Center" Margin="0,0,6,0"
|
||||||
|
KeyDown="FindBox_KeyDown"/>
|
||||||
|
<Button Content="다음" Click="FindNext_Click" Height="26" MinWidth="54" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="이전" Click="FindPrev_Click" Height="26" MinWidth="54" Margin="0,0,12,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="바꾸기" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,6,0"/>
|
||||||
|
<TextBox x:Name="ReplaceBox" Text="{Binding ReplaceText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="200" Height="26" FontFamily="Consolas" FontSize="11"
|
||||||
|
VerticalContentAlignment="Center" Margin="0,0,6,0"/>
|
||||||
|
<Button Content="바꾸기" Click="Replace_Click" Height="26" MinWidth="64" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="모두 바꾸기" Click="ReplaceAll_Click" Height="26" MinWidth="86" Margin="0,0,12,0"/>
|
||||||
|
|
||||||
|
<CheckBox Content="대소문자 구분" IsChecked="{Binding FindMatchCase}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<Button Content="찾기 접기" Command="{Binding CloseFindCommand}" Height="26" MinWidth="70"
|
||||||
|
ToolTip="Esc 로도 접힙니다"/>
|
||||||
|
</WrapPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ④ 편집기. 왼쪽에 줄 번호, 그 오른쪽에 글. 둘이 같이 굴러야 줄이 어긋나지 않는다. -->
|
||||||
|
<Border Grid.Row="3" BorderThickness="1"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
|
||||||
|
Background="{DynamicResource Ubisam.Brush.Input.Background}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
줄 번호도 <b>편집기와 같은 종류의 컨트롤</b>(TextBox)로 그린다.
|
||||||
|
TextBlock 으로 그렸더니 줄 높이가 조금씩 달라 아래로 갈수록 번호와 글이 어긋났다
|
||||||
|
(화면에서 보고 고침 - 사용자 보고 2026-09-28). 글꼴 · 크기 · 여백을 같은 값으로 두면
|
||||||
|
두 칸이 같은 자로 줄을 잰다.
|
||||||
|
-->
|
||||||
|
<Border Grid.Column="0" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
||||||
|
BorderThickness="0,0,1,0"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
|
<TextBox x:Name="LineNumbers"
|
||||||
|
IsReadOnly="True" IsTabStop="False" Focusable="False"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
BorderThickness="0" Background="Transparent"
|
||||||
|
Foreground="{DynamicResource Ubisam.Brush.Muted}"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="{Binding ElementName=Editor, Path=FontSize}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextAlignment="Right" TextWrapping="NoWrap"
|
||||||
|
VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Hidden"
|
||||||
|
VerticalContentAlignment="Top"
|
||||||
|
Padding="6,2" MinWidth="40"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- VerticalContentAlignment 을 Top 으로 <b>못 박는다</b>. 플랫폼 입력 칸 스타일은
|
||||||
|
한 줄짜리 칸을 겨냥해 가운데로 두는데, 여러 줄 편집기에서는 글이
|
||||||
|
칸 한가운데에서 시작해 위아래로 자란다(사용자 보고 2026-09-28). -->
|
||||||
|
<TextBox Grid.Column="1" x:Name="Editor"
|
||||||
|
AcceptsReturn="True" AcceptsTab="True"
|
||||||
|
BorderThickness="0" Background="Transparent"
|
||||||
|
FontFamily="Consolas" FontSize="13"
|
||||||
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalContentAlignment="Top"
|
||||||
|
TextWrapping="NoWrap" Padding="4,2"
|
||||||
|
TextChanged="Editor_TextChanged"
|
||||||
|
SelectionChanged="Editor_SelectionChanged"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ⑤ 아래 상태 줄 -->
|
||||||
|
<Border Grid.Row="4" Padding="8,5" BorderThickness="1,0,1,1"
|
||||||
|
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Right" x:Name="CaretText" Style="{StaticResource Muted}" FontSize="11"/>
|
||||||
|
<TextBlock Text="{Binding Status}" FontSize="11"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
332
src/MergeHub/Views/MemoView.xaml.cs
Normal file
332
src/MergeHub/Views/MemoView.xaml.cs
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using MergeHub.Services;
|
||||||
|
using MergeHub.ViewModels;
|
||||||
|
|
||||||
|
namespace MergeHub.Views
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 메모장 탭(12차 F-1).
|
||||||
|
///
|
||||||
|
/// 편집기 글은 <b>이 컨트롤이 들고 있고</b> ViewModel 은 필요할 때만 읽어 간다 -
|
||||||
|
/// 바인딩으로 글자마다 문서 전체를 넘기면 큰 글에서 눈에 띄게 느리다(파일 비교와 같은 까닭).
|
||||||
|
/// 줄 번호는 따로 그리고 편집기가 굴러갈 때 같이 굴린다.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MemoView : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>ViewModel 이 글을 갈아 끼우는 중. 사용자의 편집과 구분하려고 둔다.</summary>
|
||||||
|
private bool _replacing;
|
||||||
|
|
||||||
|
private ScrollViewer? _editorScroll;
|
||||||
|
|
||||||
|
public MemoView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
|
||||||
|
Editor.Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
StretchContent(Editor);
|
||||||
|
StretchContent(LineNumbers);
|
||||||
|
HookScroll();
|
||||||
|
DrawNumbers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ctrl+휠로 글자 크기(파일 비교 F-5 와 같은 손버릇).
|
||||||
|
PreviewMouseWheel += (_, e) =>
|
||||||
|
{
|
||||||
|
if (Keyboard.Modifiers != ModifierKeys.Control) return;
|
||||||
|
var size = Editor.FontSize + (e.Delta > 0 ? 1 : -1);
|
||||||
|
Editor.FontSize = Math.Max(8, Math.Min(28, size));
|
||||||
|
DrawNumbers();
|
||||||
|
e.Handled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
IsVisibleChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.NewValue is true)
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(new Action(() => Editor.Focus()),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OldValue is MemoViewModel old)
|
||||||
|
{
|
||||||
|
old.EditorTextReplaced -= OnEditorTextReplaced;
|
||||||
|
old.CaretIndexRequested -= OnCaretIndexRequested;
|
||||||
|
old.ReadEditorText = null;
|
||||||
|
old.ReadCaretIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.NewValue is MemoViewModel now)
|
||||||
|
{
|
||||||
|
now.EditorTextReplaced += OnEditorTextReplaced;
|
||||||
|
now.CaretIndexRequested += OnCaretIndexRequested;
|
||||||
|
now.ReadEditorText = () => Editor.Text;
|
||||||
|
now.ReadCaretIndex = () => Editor.CaretIndex;
|
||||||
|
|
||||||
|
// 만들어질 때 되살린 메모를 화면에 올린다.
|
||||||
|
Dispatcher.BeginInvoke(new Action(now.ShowCurrent),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditorTextReplaced(object? sender, string text)
|
||||||
|
{
|
||||||
|
_replacing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Editor.Text = text ?? string.Empty;
|
||||||
|
Editor.CaretIndex = 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_replacing = false;
|
||||||
|
}
|
||||||
|
DrawNumbers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCaretIndexRequested(object? sender, int caretIndex)
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
var at = Math.Max(0, Math.Min(caretIndex, Editor.Text.Length));
|
||||||
|
Editor.CaretIndex = at;
|
||||||
|
Editor.ScrollToLine(Editor.GetLineIndexFromCharacterIndex(at));
|
||||||
|
}), System.Windows.Threading.DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Editor_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
DrawNumbers();
|
||||||
|
if (_replacing) return;
|
||||||
|
if (DataContext is MemoViewModel vm) vm.NotifyEdited(Editor.Text, Editor.CaretIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Editor_SelectionChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var line = Editor.GetLineIndexFromCharacterIndex(Editor.CaretIndex);
|
||||||
|
var column = Editor.CaretIndex - Editor.GetCharacterIndexFromLineIndex(line);
|
||||||
|
CaretText.Text = $"{line + 1}행 {column + 1}열 · 총 {Editor.LineCount}줄";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>줄 번호를 다시 그린다. 줄 수가 그대로면 손대지 않는다.</summary>
|
||||||
|
private void DrawNumbers()
|
||||||
|
{
|
||||||
|
var lines = Editor.LineCount;
|
||||||
|
if (lines < 1) lines = 1;
|
||||||
|
|
||||||
|
var text = new StringBuilder();
|
||||||
|
for (var i = 1; i <= lines; i++)
|
||||||
|
{
|
||||||
|
text.Append(i);
|
||||||
|
if (i < lines) text.Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = text.ToString();
|
||||||
|
if (LineNumbers.Text == now) return;
|
||||||
|
|
||||||
|
LineNumbers.Text = now;
|
||||||
|
|
||||||
|
// 글을 갈아 끼우면 줄 번호 칸이 맨 위로 돌아간다 - 편집기가 보던 자리로 다시 맞춘다.
|
||||||
|
if (_editorScroll is not null) LineNumbers.ScrollToVerticalOffset(_editorScroll.VerticalOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 편집기가 굴러가면 줄 번호도 같이 굴린다.
|
||||||
|
///
|
||||||
|
/// 줄 번호 칸도 TextBox 라 <see cref="TextBox.ScrollToVerticalOffset"/> 로 같은 자리에 맞춘다 -
|
||||||
|
/// 둘이 같은 종류 · 같은 글꼴이라 <b>같은 자로 잰 자리</b>가 나온다.
|
||||||
|
/// </summary>
|
||||||
|
private void HookScroll()
|
||||||
|
{
|
||||||
|
_editorScroll = FindScroll(Editor);
|
||||||
|
if (_editorScroll is null) return;
|
||||||
|
|
||||||
|
_editorScroll.ScrollChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (Math.Abs(e.VerticalChange) < 0.01) return;
|
||||||
|
LineNumbers.ScrollToVerticalOffset(_editorScroll.VerticalOffset);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 글이 <b>맨 위부터</b> 시작하게 한다(사용자 보고 2026-09-28).
|
||||||
|
///
|
||||||
|
/// 플랫폼 입력 칸 스타일은 한 줄짜리 칸을 겨냥해 글 칸(PART_ContentHost)을 <b>세로 가운데</b>에 붙박아 둔다.
|
||||||
|
/// 그래서 여러 줄 편집기에서는 글이 칸 한가운데에서 시작해 위아래로 자랐다.
|
||||||
|
/// <c>VerticalContentAlignment</c> 로는 안 풀린다 - 템플릿 안에 박힌 값이라 그 칸을 직접 늘려야 한다.
|
||||||
|
/// </summary>
|
||||||
|
private static void StretchContent(TextBox box)
|
||||||
|
{
|
||||||
|
if (FindScroll(box) is not { } host) return;
|
||||||
|
|
||||||
|
host.VerticalAlignment = VerticalAlignment.Stretch;
|
||||||
|
host.VerticalContentAlignment = VerticalAlignment.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScrollViewer? FindScroll(DependencyObject root)
|
||||||
|
{
|
||||||
|
if (root is ScrollViewer viewer) return viewer;
|
||||||
|
|
||||||
|
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (FindScroll(System.Windows.Media.VisualTreeHelper.GetChild(root, i)) is { } found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>메모 탭을 가운데 단추로 누르면 닫는다(문서 탭과 같은 손버릇).</summary>
|
||||||
|
private void MemoTab_MouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.ChangedButton != MouseButton.Middle) return;
|
||||||
|
if (DataContext is not MemoViewModel vm) return;
|
||||||
|
if (sender is not FrameworkElement element || element.DataContext is not Memo memo) return;
|
||||||
|
|
||||||
|
if (vm.CloseMemoCommand.CanExecute(memo)) vm.CloseMemoCommand.Execute(memo);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 단축키 =================
|
||||||
|
|
||||||
|
private void View_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MemoViewModel vm) return;
|
||||||
|
|
||||||
|
var ctrl = Keyboard.Modifiers == ModifierKeys.Control;
|
||||||
|
var ctrlShift = Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift);
|
||||||
|
|
||||||
|
if (ctrl && e.Key == Key.N) { vm.NewMemoCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
if (ctrl && e.Key == Key.O) { vm.OpenFileCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
if (ctrl && e.Key == Key.S) { if (vm.SaveCommand.CanExecute(null)) vm.SaveCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
if (ctrl && e.Key == Key.W) { if (vm.CloseMemoCommand.CanExecute(null)) vm.CloseMemoCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
if (ctrl && e.Key == Key.Tab) { if (vm.NextMemoCommand.CanExecute(null)) vm.NextMemoCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
if (ctrlShift && e.Key == Key.Tab) { if (vm.PreviousMemoCommand.CanExecute(null)) vm.PreviousMemoCommand.Execute(null); e.Handled = true; return; }
|
||||||
|
|
||||||
|
if (ctrl && e.Key == Key.F)
|
||||||
|
{
|
||||||
|
vm.OpenFindCommand.Execute(null);
|
||||||
|
Dispatcher.BeginInvoke(new Action(() => { FindBox.Focus(); FindBox.SelectAll(); }),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Loaded);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Key == Key.Escape && vm.IsFindOpen)
|
||||||
|
{
|
||||||
|
vm.CloseFindCommand.Execute(null);
|
||||||
|
Editor.Focus();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 찾기 · 바꾸기 =================
|
||||||
|
|
||||||
|
private StringComparison Comparison =>
|
||||||
|
(DataContext as MemoViewModel)?.FindMatchCase == true
|
||||||
|
? StringComparison.Ordinal
|
||||||
|
: StringComparison.OrdinalIgnoreCase;
|
||||||
|
|
||||||
|
private void FindBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter) return;
|
||||||
|
FindStep(forward: true);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FindNext_Click(object sender, RoutedEventArgs e) => FindStep(forward: true);
|
||||||
|
|
||||||
|
private void FindPrev_Click(object sender, RoutedEventArgs e) => FindStep(forward: false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 다음(또는 앞) 자리로. 끝에 닿으면 <b>처음으로 돌아 한 바퀴</b> 돈다 -
|
||||||
|
/// 못 찾았다고 말하기 전에 나머지 반쪽도 봐야 한다.
|
||||||
|
/// </summary>
|
||||||
|
private void FindStep(bool forward)
|
||||||
|
{
|
||||||
|
if (DataContext is not MemoViewModel vm) return;
|
||||||
|
var what = vm.FindText;
|
||||||
|
if (string.IsNullOrEmpty(what)) return;
|
||||||
|
|
||||||
|
var text = Editor.Text;
|
||||||
|
if (text.Length == 0) { vm.Status = $"'{what}' 을(를) 찾지 못했습니다."; return; }
|
||||||
|
|
||||||
|
int at;
|
||||||
|
if (forward)
|
||||||
|
{
|
||||||
|
var from = Math.Min(Editor.SelectionStart + Math.Max(Editor.SelectionLength, 1), text.Length);
|
||||||
|
at = from >= text.Length ? -1 : text.IndexOf(what, from, Comparison);
|
||||||
|
if (at < 0) at = text.IndexOf(what, 0, Comparison);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var from = Editor.SelectionStart - 1;
|
||||||
|
at = from >= 0 ? text.LastIndexOf(what, Math.Min(from, text.Length - 1), Comparison) : -1;
|
||||||
|
if (at < 0) at = text.LastIndexOf(what, text.Length - 1, Comparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (at < 0)
|
||||||
|
{
|
||||||
|
vm.Status = $"'{what}' 을(를) 찾지 못했습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.Select(at, what.Length);
|
||||||
|
Editor.ScrollToLine(Editor.GetLineIndexFromCharacterIndex(at));
|
||||||
|
Editor.Focus();
|
||||||
|
vm.Status = $"'{what}' 을(를) 찾았습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Replace_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MemoViewModel vm) return;
|
||||||
|
if (string.IsNullOrEmpty(vm.FindText)) return;
|
||||||
|
|
||||||
|
// 지금 고른 것이 찾는 글이면 그것을 바꾸고, 아니면 먼저 찾아만 둔다.
|
||||||
|
if (Editor.SelectedText.Equals(vm.FindText, Comparison))
|
||||||
|
{
|
||||||
|
var at = Editor.SelectionStart;
|
||||||
|
Editor.SelectedText = vm.ReplaceText ?? string.Empty;
|
||||||
|
Editor.Select(at + (vm.ReplaceText?.Length ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
FindStep(forward: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MemoViewModel vm) return;
|
||||||
|
var what = vm.FindText;
|
||||||
|
if (string.IsNullOrEmpty(what)) return;
|
||||||
|
|
||||||
|
var text = Editor.Text;
|
||||||
|
var made = new StringBuilder();
|
||||||
|
var from = 0;
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var at = text.IndexOf(what, from, Comparison);
|
||||||
|
if (at < 0) break;
|
||||||
|
made.Append(text, from, at - from).Append(vm.ReplaceText ?? string.Empty);
|
||||||
|
from = at + what.Length;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0) { vm.Status = $"'{what}' 을(를) 찾지 못했습니다."; return; }
|
||||||
|
|
||||||
|
made.Append(text, from, text.Length - from);
|
||||||
|
Editor.Text = made.ToString();
|
||||||
|
vm.Status = $"{count}곳을 바꿨습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user