PySide6 웹 브라우저 만들기
PySide6 기반으로 가장 간단한 기능만 있는 가벼운 웹 브라우저를 만들고 싶어. 주요 기능은 앞/뒤 이동, 탭 관리, 주소창, 새 탭으로 열기(Ctrl 클릭 시), 탭 간 이동 단축키 등등 기초적인 기능만 있으면 돼.
좋아! PySide6(Qt WebEngine)로 “가장 기본 기능만” 갖춘 가벼운 탭형 웹 브라우저 예제를 한 파일로 깔끔하게 준비했어.
아래 스크립트 하나로 다음이 동작해:
-
뒤로/앞으로, 새로고침/정지, 홈, 주소창
-
탭 열기/닫기/이동(드래그 가능)
-
Ctrl+클릭 시 링크를 새 탭으로 열기
-
단축키:
-
새 탭
Ctrl+T, 탭 닫기Ctrl+W -
탭 순환
Ctrl+Tab/Ctrl+Shift+Tab -
탭 번호로 이동
Ctrl+1..8, 마지막 탭Ctrl+9 -
주소창 포커스
Ctrl+L -
뒤로/앞으로
Alt+←/→ -
새로고침
Ctrl+R또는F5
-
설치
pip install PySide6
참고: PySide6에는 Qt WebEngine이 포함되어 있어요(일부 리눅스에선
qtwebengine관련 시스템 패키지가 필요할 수 있음).
실행
아래 코드를 mini_browser.py로 저장 후:
python mini_browser.py
코드 (단일 파일)
#!/usr/bin/env python3
# mini_browser.py
from __future__ import annotations
import sys
import re
from urllib.parse import quote
from PySide6.QtCore import QUrl, Qt, Slot
from PySide6.QtGui import QAction, QKeySequence, QIcon, QCloseEvent, QGuiApplication
from PySide6.QtWidgets import (
QApplication, QMainWindow, QToolBar, QLineEdit,
QTabWidget, QWidget, QVBoxLayout, QStyle, QShortcut, QMessageBox
)
from PySide6.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
DEFAULT_HOME = "https://duckduckgo.com/"
DEFAULT_SEARCH = "https://duckduckgo.com/?q="
def is_probably_url(text: str) -> bool:
t = text.strip()
if re.match(r"^[a-zA-Z]+://", t):
return True
# domain-like without scheme (no spaces, has a dot)
return (" " not in t) and ("." in t)
class BrowserPage(QWebEnginePage):
"""링크 Ctrl+클릭을 가로채 새 탭으로 열기 위해 페이지를 살짝 커스터마이즈."""
def __init__(self, parent=None, on_open_new_tab=None):
super().__init__(parent)
self._on_open_new_tab = on_open_new_tab
def acceptNavigationRequest(self, url: QUrl, nav_type: QWebEnginePage.NavigationType, isMainFrame: bool) -> bool:
if nav_type == QWebEnginePage.NavigationTypeLinkClicked:
mods = QApplication.keyboardModifiers()
if mods & Qt.ControlModifier:
# Ctrl+클릭: 새 탭으로 열고 현재 탭은 그대로 유지
if self._on_open_new_tab:
self._on_open_new_tab(url, switch_to=True)
return False # 현재 뷰에선 네비게이션 취소
return super().acceptNavigationRequest(url, nav_type, isMainFrame)
class BrowserView(QWebEngineView):
"""target=_blank 또는 window.open()을 탭으로 열도록 createWindow 오버라이드."""
def __init__(self, main_window: "MainWindow"):
super().__init__(main_window)
self._mw = main_window
def createWindow(self, _type):
# 새 탭을 만들고 그 탭의 QWebEngineView를 반환
view = self._mw._create_tab_view(switch_to=True)
return view
class BrowserTab(QWidget):
"""탭 하나 = QWebEngineView 하나(툴바는 메인 윈도우 공용)."""
def __init__(self, main_window: "MainWindow"):
super().__init__(main_window)
self.view = BrowserView(main_window)
# 커스텀 페이지 부착 (Ctrl+클릭 새 탭용)
self.view.setPage(BrowserPage(self.view, on_open_new_tab=main_window.open_in_new_tab))
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.view)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Mini Browser (PySide6)")
self.resize(1100, 800)
# 탭 위젯
self.tabs = QTabWidget(self)
self.tabs.setTabsClosable(True)
self.tabs.setMovable(True)
self.tabs.currentChanged.connect(self._on_current_tab_changed)
self.tabs.tabCloseRequested.connect(self._close_tab_by_index)
self.setCentralWidget(self.tabs)
# 툴바 + 주소창
self._build_toolbar()
# 단축키
self._setup_shortcuts()
# 시작 탭
self.new_tab(QUrl(DEFAULT_HOME), switch_to=True)
# ---------- UI 구성 ----------
def _build_toolbar(self):
tb = QToolBar("Navigation", self)
tb.setMovable(False)
self.addToolBar(tb)
style = self.style()
self.act_back = QAction(style.standardIcon(QStyle.SP_ArrowBack), "뒤로", self)
self.act_back.triggered.connect(lambda: self._current_view().back())
self.act_forward = QAction(style.standardIcon(QStyle.SP_ArrowForward), "앞으로", self)
self.act_forward.triggered.connect(lambda: self._current_view().forward())
self.act_reload = QAction(style.standardIcon(QStyle.SP_BrowserReload), "새로고침", self)
self.act_reload.triggered.connect(lambda: self._current_view().reload())
self.act_stop = QAction(style.standardIcon(QStyle.SP_BrowserStop), "정지", self)
self.act_stop.triggered.connect(lambda: self._current_view().stop())
self.act_home = QAction(style.standardIcon(QStyle.SP_DirHomeIcon), "홈", self)
self.act_home.triggered.connect(lambda: self.navigate_to(QUrl(DEFAULT_HOME)))
for a in (self.act_back, self.act_forward, self.act_reload, self.act_stop, self.act_home):
tb.addAction(a)
self.urlbar = QLineEdit(self)
self.urlbar.setPlaceholderText("주소 또는 검색어 입력…")
self.urlbar.returnPressed.connect(self._on_return_pressed)
tb.addWidget(self.urlbar)
def _setup_shortcuts(self):
# 주소창 포커스
QShortcut(QKeySequence("Ctrl+L"), self, activated=lambda: (self.urlbar.setFocus(), self.urlbar.selectAll()))
# 새 탭 / 닫기
QShortcut(QKeySequence("Ctrl+T"), self, activated=lambda: self.new_tab())
QShortcut(QKeySequence("Ctrl+W"), self, activated=lambda: self._close_tab_by_index(self.tabs.currentIndex()))
# 탭 순환
QShortcut(QKeySequence("Ctrl+Tab"), self, activated=self._next_tab)
QShortcut(QKeySequence("Ctrl+Shift+Tab"), self, activated=self._prev_tab)
# 뒤/앞
QShortcut(QKeySequence("Alt+Left"), self, activated=lambda: self._current_view().back())
QShortcut(QKeySequence("Alt+Right"), self, activated=lambda: self._current_view().forward())
# 새로고침
QShortcut(QKeySequence("Ctrl+R"), self, activated=lambda: self._current_view().reload())
QShortcut(QKeySequence("F5"), self, activated=lambda: self._current_view().reload())
# 번호로 탭 전환
for i in range(1, 10):
key = f"Ctrl+{i}"
if i == 9:
QShortcut(QKeySequence(key), self, activated=lambda: self.tabs.setCurrentIndex(self.tabs.count()-1))
else:
QShortcut(QKeySequence(key), self, activated=lambda idx=i-1: self._goto_tab(idx))
# ---------- 탭 관리 ----------
def _create_tab_view(self, switch_to: bool = True) -> QWebEngineView:
tab = BrowserTab(self)
idx = self.tabs.addTab(tab, "New Tab")
if switch_to:
self.tabs.setCurrentIndex(idx)
view = tab.view
# 시그널 연결: 타이틀/URL 변경 시 탭 제목/주소창 갱신
view.titleChanged.connect(lambda title, v=view: self._update_tab_title(v, title))
view.urlChanged.connect(lambda url, v=view: self._maybe_update_urlbar(v, url))
# 초기 네비게이션 버튼 상태 갱신
view.loadFinished.connect(lambda _ok, v=view: self._update_nav_buttons(v))
view.history().changed.connect(lambda v=view: self._update_nav_buttons(v))
return view
def new_tab(self, url: QUrl | None = None, switch_to: bool = True):
view = self._create_tab_view(switch_to=switch_to)
if url is None:
url = QUrl(DEFAULT_HOME)
view.setUrl(url)
def _close_tab_by_index(self, index: int):
if self.tabs.count() == 1:
# 마지막 탭을 닫으려 하면 새 빈 탭을 열어 일관성 유지
self.new_tab(QUrl(DEFAULT_HOME), switch_to=True)
self.tabs.removeTab(0)
return
self.tabs.removeTab(index)
def _on_current_tab_changed(self, _index: int):
v = self._current_view()
if not v:
return
self._maybe_update_urlbar(v, v.url())
self._update_nav_buttons(v)
def _update_tab_title(self, view: QWebEngineView, title: str):
# 해당 view가 포함된 탭의 제목을 갱신
for i in range(self.tabs.count()):
tab = self.tabs.widget(i)
if isinstance(tab, BrowserTab) and tab.view is view:
self.tabs.setTabText(i, title if title else "New Tab")
self.tabs.setTabToolTip(i, title)
break
def _maybe_update_urlbar(self, view: QWebEngineView, url: QUrl):
# 현재 탭일 때만 주소창 동기화
current = self.tabs.currentWidget()
if isinstance(current, BrowserTab) and current.view is view:
self.urlbar.blockSignals(True)
self.urlbar.setText(url.toString())
self.urlbar.blockSignals(False)
self._update_nav_buttons(view)
def _update_nav_buttons(self, view: QWebEngineView):
hist = view.history()
self.act_back.setEnabled(hist.canGoBack())
self.act_forward.setEnabled(hist.canGoForward())
def _current_view(self) -> QWebEngineView:
tab = self.tabs.currentWidget()
return tab.view if isinstance(tab, BrowserTab) else None
def _next_tab(self):
if self.tabs.count() > 0:
self.tabs.setCurrentIndex((self.tabs.currentIndex() + 1) % self.tabs.count())
def _prev_tab(self):
if self.tabs.count() > 0:
self.tabs.setCurrentIndex((self.tabs.currentIndex() - 1) % self.tabs.count())
def _goto_tab(self, idx: int):
if 0 <= idx < self.tabs.count():
self.tabs.setCurrentIndex(idx)
# ---------- 내비게이션 ----------
def _on_return_pressed(self):
text = self.urlbar.text().strip()
if not text:
return
if is_probably_url(text):
qurl = QUrl.fromUserInput(text)
else:
qurl = QUrl(f"{DEFAULT_SEARCH}{quote(text)}")
self.navigate_to(qurl)
def navigate_to(self, url: QUrl):
v = self._current_view()
if v:
v.setUrl(url)
# Ctrl+클릭 새 탭용 콜백
def open_in_new_tab(self, url: QUrl, switch_to: bool = True):
self.new_tab(url, switch_to=switch_to)
# 창 닫힘 확인(선택 사항)
def closeEvent(self, e: QCloseEvent):
e.accept() # 필요 시 확인 대화상자 추가 가능
def main():
app = QApplication(sys.argv)
# 하이 DPI 환경에서 폰트/스케일 자동 조정
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
w = MainWindow()
w.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
작동 원리 (짧고 단계적으로)
-
QMainWindow + QTabWidget
중앙에QTabWidget을 두고 각 탭은BrowserTab(=QWebEngineView) 하나로 구성. -
툴바/주소창
뒤·앞·새로고침·정지·홈 액션과QLineEdit(주소창)을 하나의 툴바에 배치.
주소창 Enter → URL 판별(스킴/도메인) → 아니면 검색어로 DuckDuckGo 검색. -
탭 생성/제거/이동
new_tab()이 새 탭을 만들고 URL을 로드. 탭은 닫기 버튼으로 제거, 드래그로 순서 변경. -
target=_blank & Ctrl+클릭
-
BrowserView.createWindow()를 오버라이드해서target="_blank"/window.open()은 항상 새 탭 으로. -
BrowserPage.acceptNavigationRequest()에서 링크 클릭 시QApplication.keyboardModifiers()로Ctrl감지 → 새 탭으로 열기 후 기존 탭 네비게이션은 취소.
-
-
주소/제목 동기화
urlChanged/titleChanged시그널을 탭과 주소창에 반영. 현재 탭 변경 시에도 동기화. -
단축키
표준 브라우저처럼Ctrl+T/W/L,Ctrl+Tab/Shift+Tab,Ctrl+1..9,Alt+←/→,Ctrl+R/F5를 연결.
원하는 최소 기능만 담았고, 파일 하나로 바로 돌릴 수 있게 했어.
추가로 원하면 다음도 금방 붙일 수 있어:
-
다운로드 매니저/저장 위치 지정
-
간단한 히스토리/즐겨찾기 저장
-
프라이빗 모드(임시 프로필)
-
다크 모드/아이콘 교체
필요한 추가 기능 있으면 바로 얹어서 만들어 줄게!