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:
김승준 2026-09-28 16:30:24 +09:00
parent b28db92515
commit 9bd27a1960
9 changed files with 1502 additions and 6 deletions

View File

@ -285,6 +285,65 @@ BASE 칸 자신은 견줄 것이 없어 `4줄 · 기준` 이라고만 적는다.
실제로 켜서 잰 하단 탭 줄이 **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, 완료)
### 먼저 재현 (화면, 값)

View File

@ -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차 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` 로 두었다 | 아래 |

View File

@ -135,12 +135,16 @@
- **성격**: **메모 + 파일 열기 둘 다**. 기본은 이름 없는 메모 여러 장이고, 원하면 파일로 여닫고 저장도 한다.
- **보관**: **자동으로 계속** — 쓰는 도중 알아서 보관하고, 껐다 켜도 그대로 있다(E-1 과 같은 생각). 저장 단추를 눌러야 남는 방식이 아니다.
- 탭 줄 · 저장 안 한 표시 `•` · Ctrl+Tab 넘기기는 **E-2 문서 탭과 같은 것을 쓴다**(같은 모양이어야 배우는 것이 하나다).
- **정할 것**:
- 메모 보관 자리(`{설정 폴더}\Memo\` 또는 E-1 의 `Session\` 아래 같이 둘지).
- 이름 없는 메모의 탭 이름(`메모 1` · 첫 줄 몇 자 · 사용자가 붙이기).
- 파일로 연 메모와 이름 없는 메모를 같은 탭 줄에 섞을지.
- 어디까지 파일 비교와 같게 할지 — 줄 번호 · 문법 강조 · 찾기/바꾸기 · 글자 크기(Ctrl+휠) · 줄바꿈.
- 메모를 파일 비교로 보내는 길이 필요한지(메모 두 장을 견주기).
- **만들면서 정한 것**:
- **보관 자리**: `{설정 폴더}\Memo\` — E-1 의 `Session\` 과 **따로**. 저쪽은 "저장하면 사라지는 것",
메모는 "지우기 전까지 남는 것"이라 수명이 다르다. 모양(목록 한 장 + 글마다 파일)은 같다.
- **이름 없는 메모의 이름**: `메모 1` · `메모 2` **번호만**(사용자 결정). 첫 줄을 따오면 글을 고칠 때마다
탭 줄이 요동친다. 번호는 **비어 있는 가장 작은 수** — 메모 1 을 닫고 새로 만들면 다시 메모 1.
- **섞기**: 파일로 연 메모와 이름 없는 메모를 **한 탭 줄에** 둔다(notepad++ 와 같다).
- **어디까지**: 줄 번호 · 글자 크기(Ctrl+휠) · **찾기/바꾸기**(사용자 결정). 문법 강조는 넣지 않았다.
- **닫을 때**: 저장하지 않은 것이 있으면 **묻는다**(사용자 결정 2026-09-28) —
`저장하고 닫기` · `저장하지 않고 닫기` · `취소` 세 갈래. 고친 것이 없으면 묻지 않는다.
- 메모를 파일 비교로 보내는 길은 **이번에 만들지 않았다**(쓰다 필요하면 그때).
- **판정 기준**:
- 메모를 여러 장 만들어 각각 쓰고 껐다 켜면 장 수 · 순서 · 글이 그대로다(값으로 대조).
- 이름 없는 메모도 저장 단추 없이 남는다. 강제로 끝내도 마지막 보관 시점까지 남는다.

View File

@ -419,6 +419,12 @@
| **완료** | 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)으로 확인 |
### F. 메모장 탭
| 상태 | 항목 |
|---|---|
| **완료** | F-1 하단 탭에 메모장 — 메모 + 파일 열기 둘 다, **저장 단추 없이 계속 남는다**(`{설정 폴더}\Memo\`). 이름은 `메모 1` · `메모 2` 번호만(빈 번호를 다시 쓴다), 파일로 연 메모와 한 탭 줄에 섞는다. 탭 줄 · 가운뎃점 · Ctrl+Tab 은 E-2 문서 탭과 같은 모양. 줄 번호 · Ctrl+휠 글자 크기 · **찾기/바꾸기**. 닫을 때 저장하지 않은 것이 있으면 세 갈래로 묻는다(사용자 결정). 하네스 `MemoTest` 69건, 화면 30건(강제 종료 뒤 세 장 되살아남 포함) |
### D. 폴더 비교 화면 다시 만들기
| 상태 | 항목 |

View File

@ -29,6 +29,10 @@ namespace MergeHub
manager.AddMain<FolderView, FolderViewModel>(Icon.Folder, ViewIds.Folder, "폴더 비교");
manager.AddMain<HomeView, HomeViewModel>(Icon.Document, ViewIds.Home, "파일 비교");
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)
@ -52,5 +56,6 @@ namespace MergeHub
public const string Home = "MergeHub.Home";
public const string Folder = "MergeHub.Folder";
public const string Git = "MergeHub.Git";
public const string Memo = "MergeHub.Memo";
}
}

View 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) { }
}
}
}

View 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);
}
}

View 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="&#8226;" 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="&#215;" 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>

View 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}곳을 바꿨습니다.";
}
}
}