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()

작동 원리 (짧고 단계적으로)

  1. QMainWindow + QTabWidget
    중앙에 QTabWidget을 두고 각 탭은 BrowserTab(= QWebEngineView) 하나로 구성.

  2. 툴바/주소창
    뒤·앞·새로고침·정지·홈 액션과 QLineEdit(주소창)을 하나의 툴바에 배치.
    주소창 Enter → URL 판별(스킴/도메인) → 아니면 검색어로 DuckDuckGo 검색.

  3. 탭 생성/제거/이동
    new_tab()이 새 탭을 만들고 URL을 로드. 탭은 닫기 버튼으로 제거, 드래그로 순서 변경.

  4. target=_blank & Ctrl+클릭

    • BrowserView.createWindow()를 오버라이드해서 target="_blank"/window.open()은 항상 새 탭 으로.

    • BrowserPage.acceptNavigationRequest()에서 링크 클릭 시 QApplication.keyboardModifiers()Ctrl 감지 → 새 탭으로 열기 후 기존 탭 네비게이션은 취소.

  5. 주소/제목 동기화
    urlChanged/titleChanged 시그널을 탭과 주소창에 반영. 현재 탭 변경 시에도 동기화.

  6. 단축키
    표준 브라우저처럼 Ctrl+T/W/L, Ctrl+Tab/Shift+Tab, Ctrl+1..9, Alt+←/→, Ctrl+R/F5를 연결.


원하는 최소 기능만 담았고, 파일 하나로 바로 돌릴 수 있게 했어.
추가로 원하면 다음도 금방 붙일 수 있어:

  • 다운로드 매니저/저장 위치 지정

  • 간단한 히스토리/즐겨찾기 저장

  • 프라이빗 모드(임시 프로필)

  • 다크 모드/아이콘 교체

필요한 추가 기능 있으면 바로 얹어서 만들어 줄게!

Categories:

Updated: