애플 텍스트 에디터의 기술적 내부 구조
(papereditor.app)- Paper는 Apple 플랫폼에서 TextKit 1 기반
TextView편집기로 동작하며,NSTextStorage,NSTextContainer,NSLayoutManager,TextView,ScrollView가 함께 편집 경험을 구성함 NSAttributedString은 문자열과 범위별 속성(attribute) 으로 리치 텍스트를 표현하고, Paper는 Markdown 구조용 메타 속성과 화면 표시용 스타일 속성을 분리함- 스타일 갱신은 문서 열기, 텍스트 변경, 설정 변경에 따라 달라지며, 레이아웃에 영향을 주는 속성은 트랜잭션으로 묶고 장식 속성은 레이아웃 재계산 없이 갱신함
- 입력 성능은 Markdown 특수 문자 주변 편집 여부를 보고 문단 전체 재파싱을 피하는 방식으로 개선되지만, 코드 블록처럼 문서 전체 스타일에 영향을 줄 수 있는 구조는 예외 처리가 필요함
- 복사·붙여넣기는 UTI와 pasteboard의 여러 데이터 표현 위에서 동작하며, Markdown처럼 Apple이 정의하지 않은 세미공개 UTI는 앱 간 우선순위 불일치로 RTF 변환과 UX 문제가 생길 수 있음
Apple TextView 편집 스택
- Paper는 오래된 TextKit 1 프레임워크 위에 만들어져 있으며, 같은 개념과 추상화는 TextKit 2에도 그대로 있거나 더 나은 API 아래 존재함
NSTextView와UITextView는 차이가 있지만 API가 충분히 유사해 하나의TextView계열로 다룰 수 있음TextView는 OS 릴리스마다 복잡해지는 큰 컴포넌트이며, TextEdit 앱은 거의 단일TextView로 구성됨-
편집기를 이루는 핵심 계층
NSTextStorage- 원본 텍스트 문자열을 저장함
- 텍스트 범위에 붙은 문자열-값 쌍의 속성을 저장함
- 텍스트와 속성 변경 이벤트를 발생시킴
NSTextContainer- 글리프가 들어갈 영역의 모양과 크기를 정의함
- 대부분 사각형이지만 임의의 모양도 가능함
NSLayoutManagerNSTextStorage의 속성 범위를 보고 글리프 크기와 간격을 계산함- 폰트에서 벡터 글리프를 추출하고, 문자 하나를 하나 이상의 글리프로 변환함
- 각 줄의 시작·끝, 줄 수, 전체 텍스트 높이를 계산함
TextViewNSLayoutManager가 만든 글리프 레이아웃을 그림- 뷰 높이를 현재 텍스트 레이아웃 높이와 동기화함
- 입력, 선택 영역, 캐럿, 입력 속성,
textContainerInset, 받아쓰기, 복사·붙여넣기, 맞춤법 검사 등을 관리함
ScrollViewTextView의 보이는 부분, 스크롤, 스크롤바, 줌을 관리함TextView의textContainerInset과 별도로contentInset을 가질 수 있음
-
AppKit과 UIKit의 스크롤 차이
- AppKit에서는
NSScrollView가NSClipView와 두 개의NSScroller를 포함하고,NSClipView가NSTextView를 담아 여러 클래스가 스크롤 동작을 함께 만듦 - UIKit에서는
UITextView가UIScrollView를 상속하므로 스크롤 로직까지 직접 포함함 - 캐럿이
contentInset으로 제한된 보이는 영역 밖으로 이동하면UITextView가 자동 스크롤함 - iOS 편집기에서 캐럿이 키보드 뒤로 가면 다음 줄로 스크롤되는 동작은 하단
contentInset이 현재 키보드 높이로 동적으로 설정되기 때문임
- AppKit에서는
NSAttributedString과 속성 조회
NSAttributedString은 Apple 프레임워크의 리치 텍스트 편집 기반이며 두 요소로 구성됨- 일반 텍스트 문자열
- 문자열 안의 텍스트 범위에 붙은 문자열-값 속성 쌍
- 속성은 주로 스타일링에 쓰이지만, 개발자가 필요한 임의의 문자열-값 쌍도 붙일 수 있음
NSRange는location과length로 구성되며,NSMakeRange(10, 5)는 위치 10에서 시작하는 5글자 범위, 즉 10~14 위치를 뜻함- 같은 위치에 같은 속성이 여러 범위로 지정되면 마지막에 적용한 범위가 우선함
-
특정 위치의 속성과 범위 확인
- 특정 위치의 속성 값은
attribute:atIndex:effectiveRange:로 확인함 - 반환값이
nil이면 해당 속성이 없고, 반환값이 있으면 예시 기준NSFont또는UIFont객체 같은 속성 값임 - 마지막 인자로
NSRange포인터를 넘기면 같은 값의 속성이 이어지는 범위 또는 속성이 없는 공백 범위를 받을 수 있음 - 문서화상
effectiveRange는 반드시 최대 범위가 아니며 구현에 의존함 - 보장된 최대 범위가 필요하면
attribute:atIndex:longestEffectiveRange:inRange:를 사용해야 함 - 특정 위치의 모든 속성 조합과 그 조합이 이어지는 범위를 얻는 메서드도 있음
- 범위 안의 속성 순회에는
enumerateAttribute:inRange:options:usingBlock:을 사용할 수 있음
- 특정 위치의 속성 값은
Paper의 Markdown 스타일링 파이프라인
- Paper의 스타일링은 텍스트 범위에 프레임워크가 정의한 특수 속성을 적용하는 방식임
- 스타일링 전에 텍스트 구조를 식별하기 위해 커스텀 메타 속성도 사용함
-
메타 속성과 스타일 속성
- 메타 속성
- Markdown 파서가 Markdown 문법의 개별 부분을 식별하기 위해 정의함
- 순수하게 의미 정보를 위한 커스텀 문자열-값 쌍임
- 텍스트의 시각적 모양에는 영향을 주지 않음
- Markdown용 단순화된 AST처럼 볼 수 있음
- 스타일 속성
- 메타 속성으로 표시된 부분 위에 적용되는 시각 속성임
- AppKit과 UIKit이 정의한 내장 문자열-값 쌍임
- 메타 속성
-
속성 갱신을 일으키는 이벤트
- 속성은
NSTextStorage의 Markdown 텍스트와 텍스트에 영향을 주는 설정 변화에 맞춰 동기화됨 - 문서 열기에서는 메타 속성과 스타일 속성을 전체 갱신함
- 텍스트 변경에서는 영향을 받은 부분의 메타 속성과 스타일 속성을 부분 갱신함
- 설정 변경에서는 메타 속성은 바꾸지 않고 스타일 속성만 전체 갱신함
- 속성은
-
갱신 순서와 레이아웃 비용
- 먼저 텍스트 편집 트랜잭션을 시작해 속성 변경을 묶음
- 트랜잭션 없이 속성을 바꾸면 매번
NSLayoutManager의 비싼 레이아웃 재계산이 발생함 - Markdown 문자열을 파싱해 메타 속성으로 표시되는 조각으로 나누며, 설정 변경 시에는 Markdown 구조가 변하지 않으므로 이 단계를 건너뜀
- 글리프 위치나 크기에 영향을 줄 수 있는 시각 속성을 먼저 갱신하고,
NSParagraphStyle은 줄과 문단 레이아웃에 영향을 주는 가장 중요한 속성으로 다뤄짐 - 트랜잭션을 끝낸 뒤에는 레이아웃에 영향을 주지 않는 장식 속성을 갱신함
- 장식 속성은 Apple 용어로 렌더링 속성에 해당하며,
NSTextStorage가 아니라NSLayoutManager에 존재하므로 트랜잭션을 인식하지 않음
-
입력 속성
- 입력 속성(typing attributes) 은 빈 선택 영역에서는 캐럿 앞 위치의 속성, 비어 있지 않은 선택 영역에서는 선택 시작 지점의 속성과 연결됨
- 문자를 입력하면 입력 속성이 새로 삽입된 텍스트에 자동으로 적용됨
- Markdown 편집기에서는 스타일이 Markdown 문법에서 파생되므로 중요도가 낮음
- 리치 텍스트 편집기에서는 스타일을 끄거나 캐럿을 다른 위치로 옮길 때까지 캐럿에 스타일이 붙어 있어 더 중요함
- Paper의 Preview Mode는 Markdown 편집기 안에서도 토글 가능한 입력 속성이 강조되는 리치 텍스트 편집 경험을 제공함
입력 성능과 메타 속성 활용
- 메타, 레이아웃, 장식 속성 분리는 일부 편집기 변경을 빠르게 처리하는 데 유리함
- 라이트·다크 모드 전환은 장식 속성만 갱신하므로 레이아웃을 유발하지 않아 매우 빠름
- 텍스트 크기 조정 같은 설정 변경은 전체 문서 레이아웃 재계산이 필요하지만, Markdown 구조 전체 재파싱까지 하는 것보다는 빠름
-
Markdown 입력 최적화
- 텍스트 편집기의 가장 중요한 성능 요소는 입력 속도임
- Markdown 특성상 텍스트 변경 하나가 문단 전체 스타일에 영향을 줄 수 있음
- 매 키 입력마다 문단 전체를 다시 파싱하고 다시 스타일링하는 방식은 기술적으로 가장 정확하지만, 긴 문단에서는 편집 속도를 늦출 수 있음
- Paper는 입력되는 다음 문자와 주변 문자를 보고 동작을 나누는 알고리듬을 사용함
- 특수 Markdown 기호를 입력하거나 편집 위치 주변에 특수 기호가 있으면 문단 전체를 갱신함
- 그렇지 않으면 입력 속성에 의존해 대부분의 입력 상황에서 편집기 속도를 높임
-
코드 블록 예외와 값 객체 캐시
- 코드 블록은 Paper에서 유일한 다중 문단 Markdown 구조임
- 코드 블록이 있는 문서에서는 키 입력 하나가 전체 문서 스타일에 영향을 줄 수 있음
- Paper는 일정 문자 수를 넘는 문서에서는 코드 블록 처리를 무시해 코드에 관심 없는 다수 사용자에게 빠른 편집기를 유지함
- 이 선택은 개발자 인접 사용자에게도 Paper를 더 유용하게 만듦
- 복잡한 속성 값 객체는 매 키 입력마다 다시 할당되지만, 텍스트 영향 설정이 바뀌지 않는 한 변하지 않으므로 캐시해 재사용함
NSFont/UIFontNSColor/UIColorNSParagraphStyle
-
메타 속성이 쓰이는 기능
- 서식 단축키
- 선택한 Markdown 텍스트의 기존 스타일 정보를 알아야 토글이 가능함
- 선택 영역이 같은 스타일을 완전히 감싸면 해당 스타일을 제거함
- 선택 영역에 같은 스타일이 없으면 스타일을 추가함
- 선택 영역이 같은 스타일을 일부만 감싸면 스타일을 선택 영역으로 옮김
- heading과 blockquote처럼 문단 타입을 정의하는 충돌 스타일은 섞을 수 없어 새 스타일 추가 전에 제거해야 함
- 챕터 이동
- 이전 또는 다음 챕터 경계로 이동할 때 캐럿 위치 기준 heading을 찾는 데 메타 속성을 사용함
- Outline
- 모든 heading을 순회하며, outline 항목을 누르면 해당 챕터로 캐럿이 이동함
- 챕터 재배치
- outline에서 챕터를 재배열할 수 있음
- 형식 변환
- Markdown을 RTF, HTML, DOCX로 변환하려면 텍스트 구조를 알아야 함
- Paper는 외부 라이브러리를 포함하지 않기 때문에 미리 파싱된 텍스트 모델을 순회하며 출력 형식을 만듦
- 서식 단축키
텍스트 컨테이너 수학과 선택 동작
-
줄 길이와 여백 계산
- 텍스트 컨테이너의 핵심 규칙은 선호하는 줄 길이를 유지하고 남은 공간을 양쪽 inset으로 나누는 것임
- heading 태그가 일반 텍스트 흐름 바깥에 배치되는 경우처럼 대칭을 가짜로 맞춰야 하는 상황도 있음
- 이때 텍스트 컨테이너를 왼쪽으로 이동하고, 문단은
NSParagraphStyle로 들여쓰기함 - 충분한 공간이 있으면 여백을 시각적으로 대칭에 가깝게 유지함
- 추가 공간이 없으면 지정한 줄 길이를 유지하는 쪽으로 대칭을 깨지만, 오른쪽 padding이 남아 있을 때까지만 그렇게 함
- padding이 남지 않으면 선호 줄 너비보다 최소 여백이 우선함
min과max조합으로 점진적으로 접히는 여백을 구현할 수 있음
-
선택 앵커
- 텍스트 선택에는 항상 앵커 지점이 있음
- Mac에서는 클릭 후 드래그할 때 클릭한 지점이 기준이 되며, 그 지점을 지나면 선택 증감 방향이 반대로 바뀜
- iOS에서는 한쪽 선택 핸들을 드래그하면 반대쪽이 앵커가 되고, 반대의 경우도 동일함
- 키보드로 선택을 확장할 때도 같은 논리가 적용됨
Option+왼쪽·오른쪽 화살표로 단어 경계를 이동하고,Shift까지 함께 누르면 단어 단위로 선택함- 클릭·드래그로 선택한 뒤 키보드로 계속 확장하거나 줄여도 최초 클릭 지점이 앵커로 남음
-
선택 affinity
- 선택 affinity는 줄바꿈 경계에서 삽입 지점이 이전 줄 마지막 문자 뒤에 나타날지, 다음 줄 첫 문자 앞에 나타날지를 결정함
- 화살표 키로 캐럿을 움직이면 줄바꿈 지점의 공백 주변에서 단순히 줄을 전환함
- 단축키로 줄 끝으로 이동하면 캐럿이 같은 줄에 머물며 줄바꿈 공백의 오른쪽에 붙을 수 있음
TextView는 이와 비슷한 선택 affinity 동작을 다른 상황에서도 수행함
-
Undo coalescing
- Undo coalescing은 입력한 텍스트를 언제 하나의 undo 가능한 동작으로 묶을지 결정하는 알고리듬임
TextView는 다른 동작으로 중단되지 않고 입력된 내용을 함께 묶는 단순한 알고리듬을 쓰는 것으로 보임- 중간에 쉬었다가 계속 입력해도 하나의 undo로 묶이고, 줄바꿈도 coalescing을 끊지 않음
- Sublime은 입력 중 별도 단어를 undo 단위로 묶는 방식을 선호하며, 긴 단어를 입력할 때는 더 작은 undo 단위로 나누기도 함
- 이 동작에는 정답이 없으며 편집기마다 서로 다른 전략을 가질 수 있음
UTI, pasteboard, 앱 간 데이터 교환
-
Uniform Type Identifiers
- UTI는 데이터 타입이 부모 데이터 타입을 상속하듯
conform to하는 계층형 시스템임 public.*타입은 Apple이 정의하며public.html,public.jpeg같은 널리 쓰이는 형식을 식별함- 개발자는 충돌을 피하기 위해 역도메인 이름 체계로 자체 식별자를 만들 수 있음
- 계층형 시스템의 장점은 앱이 모든 텍스트 형식을 볼 수 있다면 개별 형식을 모두 나열하지 않고
public.text만 선언할 수 있다는 점임 - Paper는 모든 텍스트 파일을 열 수 있다고 선언하며, highlighting은 없더라도
.html,.rtf같은 텍스트 형식을 열 수 있음 - 파일은 크로스플랫폼 개념이므로 사실상 파일 확장자가 식별자로 쓰임
- 호환성을 위해 모든 UTI는 연결된 하나 이상의 파일 확장자를 정의할 수 있음
- Markdown은 널리 쓰이지만 Apple이 정의하지 않은 형식이라 세미공개 UTI와 관련된 까다로운 사례가 생김
- UTI는 데이터 타입이 부모 데이터 타입을 상속하듯
-
pasteboard와 여러 표현
- Apple의 pasteboard는 UTI를 직렬화된 텍스트 또는 바이너리 데이터에 매핑하는 딕셔너리임
- Xcode Additional Tools의 Clipboard Viewer로 pasteboard 내용을 실시간으로 확인할 수 있음
- 하나의 복사 동작은 같은 데이터의 여러 표현을 동시에 씀
- 일부 앱은 하위 호환성을 위해
NeXT Rich Text Format v1.0 pasteboard type같은 레거시 비-UTI 식별자도 씀 - 에디터는 일반적으로 자신이 처리할 수 있는 가장 풍부한 형식을 선택함
- 리치 텍스트 편집기의
Paste and Match Style또는Paste as Plain Text는 pasteboard의 일반 텍스트 형식을 사용하게 함 - 붙여넣은 텍스트에 적용되는 스타일은 보통 입력 속성에서 가져옴
- 드래그 앤 드롭도 다른 pasteboard를 기반으로 동작함
- 표준 copy-paste에는 general pasteboard가 사용되고, 맞춤형 앱 간 상호작용에는 커스텀 pasteboard를 만들 수 있음
-
RTF와
NSAttributedString- RTF는 기본적으로
NSAttributedString의 직렬화 형태이며, 반대로NSAttributedString은 RTF의 프로그래밍 인터페이스임 TextView는NSAttributedString의 자식 클래스인NSTextStorage위에서 동작하므로 pasteboard와 기본적으로 호환됨- 별도 코딩 없이 내용을 pasteboard에 복사할 수 있음
- RTF는 기본적으로
-
Markdown UTI의 앱 간 문제
- Markdown 에디터 간 복사·붙여넣기에서는 두 앱이 표준 프로토콜을 구현해도 문제가 생길 수 있음
- 첫 번째 에디터가 Markdown을
public.text, 리치 텍스트 표현을public.rtf로 내보내면 두 번째 에디터는 네이티브 Markdown임을 알 수 없어public.rtf를 선택할 수 있음 - 이 경우 불필요한 이중 변환이 일어나고, 앱마다 Markdown↔RTF 변환 방식이 달라 추가 줄바꿈 같은 작은 서식 문제가 생길 수 있음
- Markdown과 RTF의 근본적인 스타일 차이도 드러날 수 있음
- 두 앱이 모두
net.daringfireball.markdownUTI를 내보내고public.rtf보다 우선해야 자연스럽게 동작함 - Paper는 Markdown UTI를 내보내려 했지만, 다른 앱들이 리치 텍스트보다 이를 우선하지 않는 것으로 보였음
- Pages는
net.daringfireball.markdown을public.rtf보다 우선하지만, 리치 텍스트로 변환하지 않고 원본 Markdown 문자열을 그대로 삽입하는 동작을 보임 - 이 때문에 Paper는 Markdown UTI를 제거함
-
Paper가 RTF를 제공하는 이유와 iOS 공유
- Paper는 리치 텍스트 편집기로 자연스럽게 복사·붙여넣기되도록 RTF를 제공함
- 좋은 OS 시민으로서 복사된 데이터를 나타내는 여러 형식을 제공해야 수신 앱이 처리 가능한 가장 풍부한 형식을 고를 수 있음
- Paper에서 Markdown 텍스트를 복사해 Mail 앱에 붙여넣으면 Markdown 변형이 아니라 보기 좋게 서식화된 리치 텍스트로 붙여넣어짐
- iOS의 공유 기능은 copy-paste와 비슷하지만 UI가 더해진 형태임
- iOS 공유에서는 앱이 여러 형식으로 데이터를 내보내고 수신 앱이 원하는 형식을 선택함
- pasteboard와 달리 iOS 공유의 상단 앱 목록에 나타나려면 앱이 커스텀 UI를 가진 share extension을 명시적으로 제공해야 함
댓글과 토론
Hacker News 의견들
-
정말 훌륭함. 이제 TextKit 입문 자료로는 https://www.objc.io/issues/5-ios7/getting-to-know-textkit/보다 이 글을 먼저 보게 될 것 같음
- iOS 15.0 이상이나 MacOS Monterey 이상에서는 TextKit 2를 봐야 함. https://developer.apple.com/documentation/appkit/textkit/usi... 참고
현재 iOS와 MacOS의 텍스트 필드에서 기본으로 쓰이는 새 레이아웃 엔진이고, 앞으로는 유일한 선택지가 될 가능성이 큼
- iOS 15.0 이상이나 MacOS Monterey 이상에서는 TextKit 2를 봐야 함. https://developer.apple.com/documentation/appkit/textkit/usi... 참고
-
TextEdit 앱은 거의 전부가 단일 TextView로 구성되어 있음
Windows 쪽 대응물은 RichEdit 컨트롤 기반의 WordPad라고 봄. 또 재미있는 점은 RTF가 사실상 NSAttributedString의 직렬화 형식이라는 것임
Windows의 RichEdit 컨트롤도 마찬가지이며, 실제로는 Windows 구현이 먼저 나온 것처럼 보임: https://en.wikipedia.org/wiki/Rich_Text_Format -
편집 트랜잭션 밖에서 장식 속성을 처리한다는 부분이 조금 헷갈림. “NSLayoutManager 자체에 있고 NSTextStorage에 있지 않으므로 트랜잭션을 알지도 못한다”는 설명이 있었는데, 색상 같은 장식 속성은 보통 NSTextStorage에 들어감
저자가 Markdown 문자에 적용한 색상을 NSLayoutManager의 임시 속성 지원으로 처리한다는 뜻인가? 보통 맞춤법 오류 단어에 색을 칠할 때 쓰는 기능인데, 그 목적이 무엇인지 궁금함- 맞음. NSTextStorage도 임시 속성과 같은 속성을 담을 수 있음. TextKit 2에서는 이를 렌더링 속성이라고 부름
임시 속성을 쓰는 장점은 NSTextStorage의 전체 트랜잭션을 호출하는 것보다 변경이 훨씬 빠르다는 데 있음. 예를 들어 Paper에서는 라이트/다크 모드 전환이 매우 빠름
- 맞음. NSTextStorage도 임시 속성과 같은 속성을 담을 수 있음. TextKit 2에서는 이를 렌더링 속성이라고 부름
-
정말 좋은 글이고, 개인적으로도 지금 NSTextView를 다루는 중이라 시의적절했음
이런 글을 쓰는 데 필요한 정보는 어떻게 얻었는지 궁금함. 다른 사람의 코드인지, 고통스러운 경험인지, developer.apple.com인지?- Paper를 9년 동안 작업해 왔고, 그동안 전체 구조가 어떻게 돌아가는지 공부할 시간이 많았음
-
DOM 문서의 시대에 Notion이나 GitBook을 보면서도, 텍스트 파싱과 조작에서 마법 같은 일을 할 때는 자주 속성 문자열로 돌아가게 됨
정말 우아한 구조인데 왜 이렇게 잘 알려지지 않았는지 이해가 안 됨. 글도 믿기 어려울 만큼 좋음- 예시가 있으면 더 배워보고 싶음
- 속성 문자열을 쓰는 웹 리치 텍스트 편집기 컴포넌트가 있는지 궁금함. DOM에는 지쳤음
-
예전에 텍스트 편집기를 완전히 처음부터 직접 만들려고 시도해 본 입장에서, 이런 글이 있었다면 엄청난 자료가 됐을 것 같음
-
이 앱을 정말 잘 쓰고 있음. Obsidian과 iA Writer를 포함해 다른 모든 Markdown 앱을 대체했음
- Paper라는 이름이 적절하긴 하지만, 기억이 맞다면 iOS 앱 중에 “Paper”라는 이름을 가진 앱이 적어도 두 개는 더 있음
- 흥미롭다. 내가 글쓰기용으로 주로 쓰는 두 앱이 바로 그 둘인데, 이 앱도 확인해 봐야겠음
-
더 많은 iOS 컴포넌트에 이런 문서가 있었으면 좋겠음
-
2024년에 그래도 누군가는 Cocoa를 하고 있다니 다행임
-
오래 Android 앱을 개발해 온 입장에서는 Apple이 일을 조금 다르고 더 신중하게 접근하는 방식이 흥미로웠음
Android에서는 기본적으로 레이아웃과 렌더링 관련 모든 일을 Layout 클래스와 그 하위 클래스가 처리하고, TextView가 편집과 선택 로직 일부를 구현함. EditText와 TextView의 유일한 차이는 EditText가 TextView에 이미 들어 있는 편집 기능을 켜 둔다는 것임
이렇게 다소 단일 덩어리인 접근과 빈약한 API의 문제는, 앱의 텍스트 렌더링을 더 세밀하게 제어하고 싶을 때 막힌다는 데 있음. 예를 들어 레이아웃이 끝난 뒤 개별 글리프에 접근하고 싶어도 불가능함