## 책 정보
[밑바닥부터 시작하는 웹 브라우저](https://www.hanbit.co.kr/store/books/look.php?p_code=B6818199506)
> [!NOTE]
> 코드를 계속 수정하면서 기록했기 때문에 초반의 코드와 후반의 코드가 좀 다를 수 있습니다.
드디어 GUI 적용 👏
## 창 만들기
프로그램은 자신의 창을 제어하고, 데스크톱 환경은 화면을 제어하는 식으로 역할 분리가 되어 있음.
파이썬에서는 [tkinter](https://wiki.python.org/moin/TkInter)라는 파이썬 패키지가 Tk라는 그래픽 툴킷을 지원한다. 내 환경에는 tkinter가 포함되어 있지 않은 것 같아서 추가로 설치해줬다... 🤔
```bash
brew install python-tk
```
```python
import tkinter
window = tkinter.Tk()
window.mainloop()
```
![[8dbc5ce4-78e9-4c4b-828c-b3d0f5e39ea7.png]]
mainloop가 이벤트를 받아서 이벤트를 처리하고, 화면을 그리는 루프를 계속 돌리는 역할을 한다.
## 창에 그리기
캔버스를 만들 수 있다. 창을 생성하고, 캔버스를 생성할 때 그 창에다 캔버스를 올릴 것인지를 명시하면 된다. 이걸 이용해서 브라우저 클래스를 만듭시다.
```python
class Browser:
WIDTH, HEIGHT = 800, 600
def __init__(self):
self.window = tkinter.Tk()
self.canvas = tkinter.Canvas(self.window, width=self.WIDTH, height=self.HEIGHT)
self.canvas.pack()
```
로드 함수를 Broswer 클래스 안으로 옮겨서 도형을 그려봅시다.
```python
# 웹페이지 로드하기
def load(self, url: URL, limit=0):
# ...
self.canvas.create_rectangle(10, 20, 400, 300)
self.canvas.create_oval(100, 100, 150, 150)
self.canvas.create_text(200, 200, text="Hello, Browser!")
```
그리고 메인 실행부에서 Browser 객체를 만들어서 load 메서드를 실행
```python
if __name__ == "__main__":
# 첫번째 인자를 URL로 사용 (없을 수도 있음)
Browser().load(URL(sys.argv[1] if len(sys.argv) > 1 else ""))
tkinter.mainloop()
```
![[a90ddf3f-6e44-4d79-a0d1-6a6b5a791279.png]]
Tk 좌표계는 좌상단을 (0, 0)으로 잡는다.
## 텍스트 배치하기
이제 터미널에 출력하던 응답 body를 캔버스에 그려봅시다. 이전의 show 함수를 lex로 이름을 바꾸고 바로 출력하는 대신 반환하도록 수정하자. (lex는 구문분석하는 [lexer](https://en.wikipedia.org/wiki/Lexical_analysis)에서 온 이름이다.)
```python
def lex(body):
text = ""
i = 0
while i < len(body):
c = body[i]
# ...
elif not in_tag:
text += c
i += 1
return text
```
그리고 load 메서드에서 반환받은 text를 하나하나 화면에 찍어준다.
```python
text = lex(body_text)
# 캔버스에 출력하기 (함수 분리하고 싶음 ㅜㅜ)
HSTEP, VSTEP = 13, 18
cursor_x, cursor_y = HSTEP, VSTEP
for c in text:
self.canvas.create_text(cursor_x, cursor_y, text=c)
cursor_x += HSTEP
# 줄바꿈
if cursor_x >= self.WIDTH - HSTEP:
cursor_x = HSTEP
cursor_y += VSTEP
```
![[baf40363-e70d-4983-b600-0f4dbe4e89c5.png]]
## 텍스트 스크롤하기
스크롤을 위해서는 페이지의 좌표계와 화면의 좌표계 사이에 레이어 개념이 필요하다.
![[d960f19e-cde2-400c-8468-53752e820a88.png]]
출처 : [Drawing to the Screen \| Web Browser Engineering](https://browser.engineering/graphics.html)
실제 문서에서 132픽셀에 있는 내용이 브라우저에서는 72픽셀에 위치하게 되는 식으로 페이지와 화면의 위치가 달라야 하는데, 이렇게 페이지의 내용이 어디에 위치할지를 계산하는 과정을 레이아웃이라고 한다.
지금은 load 함수에서 layout과 rendering을 모두 수행하고 있는데, 이 과정을 두개로 나눠서 layout과 draw함수로 분리해봅시다. 이렇게 하면 layout에서는 페이지 좌표계로만 작동하고, draw 함수에서는 화면 좌표계로만 작동하게 된다.
layout은 브라우저 객체 내부를 직접 참조하지 않기 때문에 독립적인 함수로 구현할 수 있다.
> [!NOTE]- layout과 브라우저 객체가 과연 독립적일까
> layout 안에서 줄바꿈을 할 때 CANVAS_WIDTH 상수를 활용한다.
> 그런데 CANVAS_WIDTH 상수는 브라우저 객체의 멤버인 canvas의 크기에 사용되는 상수이다.
> 이런 점에서 브라우저 객체랑 layout 함수가 과연 독립적이라고 할 수 있는지 좀 의문이 생겼다. notebookllm이 설명한 바로는 CANVAS_WIDTH 상수 자체가 브라우저의 런타임 상호작용에 의해 변경되는 값이 아니기 때문에, layout도 브라우저 객체의 동적인 상태변화로부터 독립적이라고 할 수 있다고 한다. ~~잘 모르겠음~~
> > [!QUOTE]- notebookllm의 답변
> > **1. `layout` 독립성의 진정한 의미: 동적 상태로부터의 분리**
> >
> > `layout` 함수가 독립적이라는 의미는 **브라우저 인스턴스의 동적으로 변화하는 상태**로부터 분리되어 있다는 뜻입니다.
> >
> > 브라우저는 텍스트를 배치하고 그리는 과정을 **레이아웃(Layout)과 렌더링(Draw)으로 분리**합니다. 이 분리의 주된 목적은 **스크롤 시의 성능 최적화**입니다 (81쪽).
> >
> > **`layout` 함수의 책임 (페이지 좌표계)**
> >
> > `layout` 함수는 다음 사항에만 집중합니다.
> >
> > 1. **페이지 좌표계 사용:** `layout`은 스크롤의 영향을 받지 않는 절대적인 **페이지 좌표계**에서 모든 요소의 위치를 결정합니다 (81쪽).
> > 2. **고정된 입력값 사용:** 텍스트를 어디에 배치할지, 즉 줄바꿈(wrap)을 언제 할지를 결정하기 위해 고정된 창의 너비 상수인 `WIDTH`를 사용합니다 (77쪽).
> >
> > 소스에 따르면, `layout`은 **"Browser 객체의 어떤 것도 액세스할 필요가 없으므로 독립형 함수가 될 수 있습니다."** (81쪽)라고 설명합니다. 여기서 핵심은 `Browser` 객체의 _상태_를 직접 참조하지 않는다는 점입니다.
> >
> > **1. `WIDTH`의 역할: 동적 상태가 아닌 정적 제약 조건**
> >
> > 사용자님께서 의문을 가지신 것처럼, `WIDTH`는 캔버스의 너비이자 줄바꿈의 오른쪽 경계를 설정하는 값입니다. 하지만 이 `WIDTH`는 `Browser` 객체의 **동적 상태**가 아니라 **전역 상수**로 정의됩니다.
> >
> > **`WIDTH`는 고정된 값 (74쪽 참고)**
> >
> > 책의 코드에서는 `WIDTH`와 `HEIGHT`를 `Browser` 클래스 외부에 전역 상수로 정의합니다.
> >
> > ```
> > WIDTH, HEIGHT = 800, 600
> > ```
> >
> > 이 상수는 이후 `Browser` 객체가 생성될 때 캔버스 크기를 설정하는 **기준값**으로 사용됩니다 (74쪽, 75쪽 참고).
> >
> > `layout` 함수가 `WIDTH`를 참조할 때, 이는 브라우저 인스턴스 내부의 실시간으로 변경될 수 있는 **캔버스 객체(`self.canvas`)나 스크롤 상태**를 읽는 것이 아니라, 프로그램 전체에 걸쳐 고정된 **설계상의 제약 조건**을 읽는 것입니다.
> >
> > **3. 종속성을 판단하는 기준: 상태 vs. 지오메트리**
> >
> > `layout` 함수가 독립적이라고 말할 수 있는 기준은 **`Browser` 인스턴스의 런타임 상태**를 사용하지 않는다는 점에 있습니다.
> > |변수|`layout`과의 관계|타입|
> > |:-:|:-:|:-:|
> > |**`WIDTH`**|**입력 매개변수 (또는 전역 상수):** 줄바꿈 경계 설정|**정적** (고정된 크기)|
> > |**`self.scroll`**|**접근 안 함:** 현재 스크롤 위치|**동적** (계속 변함)|
> > |**`self.canvas`**|**접근 안 함:** 그리기 객체|**동적** (렌더링 책임)|
> >
> > 만약 스크롤이 발생할 경우 (`self.scroll`이 변경될 경우), `draw` 함수만 호출되어 `y - self.scroll`을 계산하면 됩니다 (82쪽). 이 과정에서 `layout` 함수는 **다시 실행될 필요가 없습니다** (83쪽 참고).
> >
> > `layout`이 `WIDTH`를 사용하더라도, `WIDTH`는 스크롤과 같은 브라우저의 런타임 상호작용에 의해 변경되지 않는 고정된 _지오메트리_ 정보이기 때문에, `layout`은 브라우저의 **"동작 상태"에 종속되지 않는 독립적인 배치 계산기** 역할을 수행할 수 있는 것입니다.
> >
> > 따라서 `layout` 함수를 독립적으로 설계한 것은 **불필요한 계산을 건너뛰어 성능을 최적화**하기 위한 핵심적인 아키텍처 결정입니다.
```python
def layout(text):
HSTEP, VSTEP = 13, 18
display_list = []
cursor_x, cursor_y = HSTEP, VSTEP
for c in text:
display_list.append((cursor_x, cursor_y, c))
cursor_x += HSTEP
# 줄바꿈
if cursor_x >= CANVAS_WIDTH - HSTEP:
cursor_x = HSTEP
cursor_y += VSTEP
return display_list
```
draw 함수에서는 숫자를 위치에 맞게 canvas에 찍어주고 (이건 browser 객체의 멤버함수다)
```python
def _draw(self):
for x, y, c in self.display_list:
self.canvas.create_text(x, y - self.scroll, text=c)
```
load에서는 layout과 draw를 호출해준다.
```python
# 웹페이지 로드하기
def load(self, url: URL, limit=0):
# ...
else:
# ...
self.display_list = layout(lex(body_text))
self.draw()
```
스크롤 위치를 변화시키기 위해서 이벤트 핸들러를 바인딩해줘야 한다. 아래 화살표 키만 바인딩해준다.
```python
def __init__(self):
# ...
self.window.bind("<Down>", self._scrolldown)
def _scrolldown(self, event):
SCROLL_STEP = 100
self.scroll += SCROLL_STEP
self.canvas.delete("all")
self._draw()
```
![[b8c35ba0-1e04-45ce-80d0-0fa7b89af062.gif]]
## 더 빠른 렌더링
스크롤이 가능해졌지만 버벅거린다. 보통 화면의 갱신 속도인 **frame rate**를 60Hz에 맞춰서 화면을 다시 그리면 자연스러운 화면 전환이 가능해진다. (오늘날 대부분의 모니터 주사율이 60Hz이기 때문인데, 더 높은 주사율의 모니터도 있긴 하다) 그래서 브라우저가 자연스러운 화면을 그리기 위해서는 16ms 안에 모든 작업을 완료해야 하고, 16ms를 **Animation Frame Budget**이라고 한다.
우리 경우에서는 create_text안에서 문자의 모양을 로드하는데 시간이 너무 오래 걸리기 때문에 이 문제가 발생하는 것이다. 실제 브라우저에서는 좀 더 까다로운 최적화를 하지만, 우리는 화면 밖에 있는 문자는 그리지 않는 식으로만 최적화를 해본다.
```python
def _draw(self):
for x, y, c in self.display_list:
if y > self.scroll + CANVAS_HEIGHT: continue
if y + VSTEP < self.scroll: continue
self.canvas.create_text(x, y - self.scroll, text=c)
```
> [!QUOTE]- 빨라졌다!
> ![[f58bb674-a98a-4b5e-99a8-06aba59f8ed7.gif]]
화면에 그리는 부분에서의 최적화였기 때문에 layout을 전혀 수정할 필요 없었다는 점도 주목할 만 하다.
> [!NOTE]-
> 웹 페이지의 모든 상호작용이 애니메이션처럼 부드럽게 동작해야 하는 것은 아니다. 대표적인게 마우스 클릭 같은 것인데, 일반적으로는 100ms 이내에만 반응하면 충분하고, 대부분의 사람들이 불편함을 느끼지 않는다고 한다.
## 요약
여기까지 구현한 실습 브라우저는 다음 링크에서 확인할 수 있다.
[browser.engineering/widgets/lab2-browser.html](https://browser.engineering/widgets/lab2-browser.html)
## 연습 문제
### 2-1 줄바꿈
줄바꿈 문자가 보이면 새 줄을 시작하도록 layout 변경하기
단락이 바뀌었음을 확실히 알 수 있도록 VSTEP 이상으로 간격을 띄운다.
```python
def layout(text):
# ...
for c in text:
if c == "\n":
cursor_x = HSTEP
cursor_y += VSTEP * 2
continue
# ...
```
![[68ca3ba1-a099-4295-89d9-91c4ec0e35c3.png]]
### 2-2 마우스 휠
위 화살표 키를 눌러서 위로 스크롤 할 수 있게 하기 (범위 제한 두기)
아래쪽 스크롤 범위 제한은 [[2. 화면에 그리기#2-4 스크롤바]]에서 추가될 예정이다.
스크롤이 변경되었을 때만 새로 화면을 그리도록 했다.
```python
def _scrollup(self, event):
new_scroll = max(0, self.scroll - self.SCROLL_STEP)
if new_scroll == self.scroll:
return
self.scroll = new_scroll
self.canvas.delete("all")
self._draw()
```
![[12edd25b-d276-4869-b244-c5758c364a8f.gif]]
마우스휠을 이용해서 스크롤 할 수 있게 하기
> [!note]- 마우스휠 이벤트가 안잡히는 버그 (해결 ㅠㅠ)
> ~~3시간은 쓴 것 같다...~~
> 내 노트북에 기본적으로 깔려 있던 파이썬 버전은 3.14.0, 그리고 tcl-tk는 9.0.3인데, (가장 최신버전 썼다. ([
[email protected] — Homebrew Formulae](https://formulae.brew.sh/formula/
[email protected]))) 마우스 휠 이벤트(`<MouseWheel>`)핸들링이 안되는 문제가 있었다.
> 해결 사례를 못찾겠어서 고생을 했는데, 혹시 몰라서 다운그레이드 해봤더니 그게 맞았다 (황당)
> tcl-tk 8.6.17을 사용하는 python 3.11 버전([
[email protected] — Homebrew Formulae](https://formulae.brew.sh/formula/
[email protected]))을 사용했더니 마우스 휠 이벤트가 아주 잘! 처리가 되었다.
```python
def __init__(self):
# ...
self.window.bind("<MouseWheel>", self._on_mousewheel) # 맥 기준
def _on_mousewheel(self, event):
# macos "자연스러운 스크롤" 모드 기준 (윈도우와 반대로 동작)
if event.delta < 0:
self._scrolldown(event)
else:
self._scrollup(event)
```
### 2-3 크기 조절
pack 메서드 : [tkinter — Python interface to Tcl/Tk — Python 3.14.0 documentation](https://docs.python.org/3/library/tkinter.html#the-packer)
컨테이너 안에서 위젯의 상대적 위치를 지정하는 메서드이다.
```python
canvas.pack() # 기본값이 side = "top"
```
문제에 적혀 있는대로 fill의 값을 "both"로 설정해서 캔버스가 창의 가로, 세로를 모두 채우도록 지정하고 expand를 true로 설정해서 부모 위젯을 캔버스가 채울 수 있도록 설정했다. 그다음 Configure 이벤트 핸들러 걸어줌
```python
def __init__(self):
# ...
self.canvas.pack(fill="both", expand=True)
# ...
self.window.bind("<Configure>", self._on_configure)
```
이벤트 핸들러 안에서 layout을 호출해야 했기 때문에 lex 메서드의 결과물을 lexed_text 멤버로 새로 저장해주고, 이벤트 핸들러에서 전역변수 수정 후에 layout, draw 메서드를 호출해줬다.
```python
def load(self, url, limit=0):
# ...
self.lexed_text = lex(body_text)
self.display_list = layout(self.lexed_text)
self._draw()
def _on_configure(self, event):
# 캔버스 크기 변경 처리
global CANVAS_WIDTH, CANVAS_HEIGHT
CANVAS_WIDTH = event.width
CANVAS_HEIGHT = event.height
self.canvas.delete("all")
# 레이아웃부터 다시 계산
self.display_list = layout(self.lexed_text)
self._draw()
```
![[df492880-8919-4ee4-9064-05b2cb749e00.gif]]
~~너무신기해~~
### 2-4 스크롤바
우선 layout 메서드에서 가장 마지막에 배치된 요소의 y 좌표를 document_height로 반환하도록 수정했다. 이것 역시 브라우저 내부에서 접근해야 되기 때문에 멤버로 저장해둔다.
```python
def layout(text):
# ...
document_height = cursor_y
return document_height, display_list
class Browser:
# 웹페이지 로드하기
def load(self, url, limit=0):
# ...
self.document_height, self.display_list = layout(self.lexed_text)
self._draw()
```
그리고 스크롤 가능한 최대 스크롤을 계산한다. 전체 문서에서 CANVAS_HEIGHT 만큼의 CANVAS가 바닥에 닫는 지점을 계산해서 max_scroll을 구해줬다.
![[96de72b2-a900-439d-abd8-78f6c2594f67.png]]
그리고 스크롤 길이가 그만큼을 넘지 않도록 조건을 걸어줬다.
```python
def _scrolldown(self, event):
max_scroll = max(0, self.document_height - CANVAS_HEIGHT)
if self.scroll >= max_scroll:
return
self.scroll = min(max_scroll, self.scroll + self.SCROLL_STEP)
# ...
```
스크롤바를 그리는 작업은 draw 함수에서 이루어져야 한다.
우선 스크롤바 표시 여부를 결정한다.
```python
def _draw(self):
document_height = self.document_height
# 전체 문서가 창 안에 모두 들어가는 경우에는 스크롤바를 그리지 않음
if document_height <= CANVAS_HEIGHT:
return
```
그리고 스크롤바를 그려주면 됨
```python
# 스크롤바 그리기에 필요한 값 계산
max_scroll = document_height - CANVAS_HEIGHT
thumb_height = CANVAS_HEIGHT * CANVAS_HEIGHT / document_height
track_height = CANVAS_HEIGHT - thumb_height
thumb_top = self.scroll / max_scroll * track_height
# 스크롤바 트랙 그리기
x1 = CANVAS_WIDTH - self.SCROLLBAR_WIDTH
y1 = thumb_top
x2 = CANVAS_WIDTH
y2 = thumb_top + thumb_height
self.canvas.create_rectangle(
x1, y1, x2, y2,
fill="#E5CDFF", outline="#E5CDFF", width=0 # 아웃라인 제거용
)
```
> [!note]- 스크롤바 구현 완료
> ![[4c97fb40-8d75-4dfd-8da7-186cc2bef8d8.gif]]
### 2-5 이모지
화질이 좋지 않다는데 동의하긴 좀 어렵다. 괜찮은데?
![[640b84b6-2e30-4a83-9a07-5b0e89576799.png]]
> 구조적으로 변경해야 할 것이 많을 것 같은데, 뒤에서 어차피 또 수정해야 할 부분일 것 같기도 하고, 무엇보다 이미지로 올리는 것 보다 그냥 create_text로 출력하는 것이 더 화질이 괜찮아서 연습문제 2-5는 넘어갔습니다. 😩
> [!tip]- 이미지 리사이징 한방에 하기
> [GitHub - ImageMagick/ImageMagick: ImageMagick is a free, open-source software suite for creating, editing, converting, and displaying images. It supports 200+ formats and offers powerful command-line tools and APIs for automation, scripting, and integration across platforms.](https://github.com/ImageMagick/ImageMagick)
> ```bash
> brew install imagemagick
> ```
> 이미지 디렉토리 들어가서
> ```bash
> mogrify -resize 16x16! *.png
> ```
### 2-6 about\:blank
about도 스킴이다. ([rfc-editor.org/rfc/rfc6694.txt](https://www.rfc-editor.org/rfc/rfc6694.txt))
`about:blank` 로 접속했을 때 빈 화면이 떠야 하고, 유효하지 않은 페이지에 접속했을 때에도 동일한 페이지를 보여주도록 해보자.
```python
class URL:
def __init__(self, url: str):
# ...
# about 스킴 처리
if self.scheme == "about":
if remaining == "blank":
self.port = None
self.host = None
self.path = BLANK_PATH
return
else:
raise Exception(f"지원하지 않는 about URL입니다: {remaining}")
```
request 메서드에서는 file 스킴과 거의 비슷하게 처리해준다.
```python
def request(self):
# ...
if self.scheme == "about":
if self.path == DEFAULT_PATH:
with open(self.path, "r", encoding="utf8") as f:
return {"body": f.read().encode("utf8")}
```
비정상적인 url이 등장했을 때에 blank 페이지를 보여주기 위해서는 load 함수에서 try-except로 감싸주면 된다.
```python
# 웹페이지 로드하기
def load(self, url, limit=0):
try:
# ...
except:
self._blank_page()
```
### 2-7 텍스트 방향
[browser.engineering/examples/example2-rtl.html](https://browser.engineering/examples/example2-rtl.html) 이렇게 출력되는 브라우저를 만들면 된다.
커맨드라인으로 실행할 때 두번째 인자로 rtl이 들어오면 출력 방향을 바꿀 수 있도록 했다. 브라우저에서는 이 값을 저장해두었다가 layout을 호출할 때마다 함께 전달한다.
```python
if __name__ == "__main__":
# 첫번째 인자를 URL로 사용 (없을 수도 있음)
url = URL(sys.argv[1] if len(sys.argv) > 1 else "")
# 두번째 인자가 RTL이면 오른쪽에서 왼쪽으로
rtl_mode = len(sys.argv) > 2 and sys.argv[2].lower() == "rtl"
Browser(rtl_mode).load(url)
tkinter.mainloop()
```
layout에서는 cursor_x의 움직임만 신경써주면 된다. 왼쪽으로 움직이냐, 오른쪽으로 움직이냐를 플래그값을 이용해서 결정해줬다. (수많은 삼항연산자... 😩)
```python
def layout(text, rtl_mode):
display_list = []
cursor_x = HSTEP if not rtl_mode else CANVAS_WIDTH - HSTEP
cursor_y = VSTEP if not rtl_mode else VSTEP
for c in text:
if c == "\n":
cursor_x = HSTEP if not rtl_mode else CANVAS_WIDTH - HSTEP
cursor_y += VSTEP
continue
display_list.append((cursor_x, cursor_y, c))
cursor_x += HSTEP if not rtl_mode else -HSTEP
# 줄바꿈
if cursor_x >= CANVAS_WIDTH - HSTEP:
cursor_x = HSTEP if not rtl_mode else CANVAS_WIDTH - HSTEP
cursor_y += VSTEP
document_height = cursor_y
return document_height, display_list
```
여기까지 하면 이렇게 처리된다. 사실 단어 단위는 순서가 바뀌면 안되는데, 우리는 글자 단위로 찍어주기 때문에 이렇게 단어 안에서도 순서가 반전된다. 이것은 단어 단위로 처리해주면 해결되는 문제인데, [[3. 텍스트 포맷팅하기#한 단어씩 처리하기|3장]]에서 바로 이 부분이 나온다고 해서 우선은 넘어간다.
![[5f8b1a32-fe6d-4f8e-8126-c8d3c642d459.png]]