YOGYUI

PyQt5 - QtWebEngine::웹브라우저 만들기 (2) 본문

Software/Python

PyQt5 - QtWebEngine::웹브라우저 만들기 (2)

요겨 2021. 9. 5. 18:43
반응형

 

3. 윈도우 만들기

앞서 구현한 WebPageWidget 여러개를 하나의 윈도우가 관리할 수 있도록 QTabWidget을 사용해서 윈도우를 만들어보자

 

[WebBrowserWindow.py]

from functools import partial
from PyQt5.QtCore import QSize
from PyQt5.QtGui import QIcon, QCloseEvent
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QTabBar, QPushButton
from WebPageWidget import WebPageWidget

class WebBrowserWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent=parent, init_url: str = 'about:blank')
        path_ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        if os.getcwd() != path_:
            os.chdir(path_)
        self._tabWidget = QTabWidget()
        self.initControl()
        self.initLayout()
        self.addWebPageTab(init_url)

    def release(self):
        self.closeWebPageAll()

    def initLayout(self):
        self.setCentralWidget(self._tabWidget)

    def initControl(self):
        self._tabWidget.setMovable(True)
        # add "add tab view" button 
        btn = QPushButton()
        btn.setIcon(QIcon('./Resource/add.png'))
        btn.setFlat(True)
        btn.clicked.connect(lambda: self.addWebPageTab())
        self._tabWidget.setCornerWidget(btn)

    def addWebPageTab(self, url: str = 'about:blank'):
        view = WebPageWidget(parent=self, url=url)
        self._tabWidget.addTab(view, 'Empty')
        # add close button in tab
        index = self._tabWidget.indexOf(view)
        btn = QPushButton()
        btn.setIcon(QIcon('./Resource/close.png'))
        btn.setFlat(True)
        btn.setFixedSize(16, 16)
        btn.setIconSize(QSize(14, 14))
        btn.clicked.connect(partial(self.closeWebPageTab, view))
        self._tabWidget.tabBar().setTabButton(index, QTabBar.RightSide, btn)
        self._tabWidget.setCurrentIndex(index)

    def closeWebPageTab(self, view: WebPageWidget):
        index = self._tabWidget.indexOf(view)
        self._tabWidget.removeTab(index)
        view.release()

    def closeWebPageAll(self):
        views = [self._tabWidget.widget(i) for i in range(self._tabWidget.count())]
        for view in views:
            idx = self._tabWidget.indexOf(view)
            if isinstance(view, WebPageWidget):
                self._tabWidget.removeTab(idx)
                view.release()

    def closeEvent(self, a0: QCloseEvent) -> None:
        self.release()

브라우저 윈도우는 간단하게 QMainWindow를 상속해서 구현했다

주요 구현 사항은 다음과 같다

  • 탭위젯의 우측 코너에 웹페이지 탭 추가 기능 (버튼)
  • 탭은 움직일 수 있도록 setMovable(True)
  • 각 탭에는 '탭 닫기' 버튼을 추가 (탭위젯을 tabBar::setTabButton으로 구현 가능)
  • 웹페이지 탭 추가, 닫기, 모두 닫기 기능은 윈도우 클래스에 메서드로 구현
  • 윈도우 최초 로드 시 탭 한개 추가

잘되는지 테스트해보자

if __name__ == '__main__':
    import sys
    from PyQt5.QtWidgets import QApplication
    from PyQt5.QtCore import QCoreApplication

    QApplication.setStyle('fusion')
    app = QCoreApplication.instance()
    if app is None:
        app = QApplication(sys.argv)
    wnd_ = WebBrowserWindow()
    wnd_.show()
    wnd_.resize(600, 600)

    app.exec_()

 

탭을 모두 닫으면 탭바가 사라지는 문제가 있다

이제 살을 붙여나가보자

3.1. Add Tab 기능을 가진 탭 고정하기

일반 브라우저들을 보면 탭에 '+' 버튼이 고정되어 있는 경우가 대다수다

구글 크롬의 '+' 버튼

마찬가지로 고정된 탭 하나를 가진 QTabWidget을 상속한 클래스를 하나 만들어보자

[CustomTabWidget.py]

class CustomTabWidget(QTabWidget):
    sig_add_tab = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__(parent=parent)
        self.setMovable(True)

        btn = QPushButton()
        btn.setIcon(QIcon('./Resource/add.png'))
        btn.setFlat(True)
        btn.setFixedSize(16, 16)
        btn.setIconSize(QSize(14, 14))
        btn.clicked.connect(lambda: self.sig_add_tab.emit())

        self._defaultWidget = QWidget()
        self.addTab(self._defaultWidget, '')
        self.tabBar().setTabButton(0, QTabBar.LeftSide, btn)
        self.setTabEnabled(0, False)
        self.tabBar().tabMoved.connect(self.onTabMoved)

    def onTabMoved(self):
        # add tab should be always on right side
        index = self.indexOf(self._defaultWidget)
        self.tabBar().moveTab(index, self.count() - 1)

+ 탭은 항상 최우측에 고정하기 위해 TabMoved 시그널 발생 시 해당 탭은 항상 최우측으로 moveTab을 호출하는 방향으로 구현했다

QTabBar를 커스터마이즈해서 버튼을 직접 그리는 방식도 있는데, 귀차니즘이 발동해서 그냥 쉽게 구현했다

 

커스터마이즈한 탭 위젯을 사용해보자

class WebBrowserWindow(QMainWindow):
    def __init__(self, parent=None, init_url: str = 'about:blank'):
        # ...
        self._tabWidget = CustomTabWidget()
    
    def initControl(self):
        self._tabWidget.sig_add_tab.connect(self.addWebPageTab)

 

보기에도 이상하고 작동 방식도 괴랄하지만 그래도 원하는 방향대로 충실하게 구현되었다

TODO: QTabBar의 특정 탭의 폭을 고정하는 방법, 특정 탭의 경계선을 그리지 않는 방법, 특정 영역 이상으로 탭이 드래그되지 않게 하는 방법 등 여러가지 스킬을 활용하면 좀 더 시중의 브라우저와 유사하게 만들 수 있을 것 같다

 

3.2. 새 탭에서 열기, 새 윈도우에서 열기 기능 추가

브라우저의 핵심 기능은 보고자 하는 웹페이지를 새로운 탭에서 열거나(open link in new tab), 혹은 새로운 브라우저 창을 오픈하는 것(open link in new window)인데, QWebEngineView의 createWindow 메서드를 오버라이드해서 구현해줘야 한다

공식 홈페이지의 가이드를 따라 간단하게 구현해보자

 

기존에 구현해둔 QWebEngineView 상속 클래스 WebView에 createWindow 메서드를 아래와 같이 오버라이드해준다

class WebView(QWebEngineView):
    sig_new_tab = pyqtSignal(QWebEngineView)
    sig_new_window = pyqtSignal(QWebEngineView)
    # ...
    def createWindow(self, windowType):
        if windowType == QWebEnginePage.WebBrowserTab:
            view = WebView()
            self.sig_new_tab.emit(view)
            return view
        elif windowType == QWebEnginePage.WebBrowserWindow:
            view = WebView()
            self.sig_new_window.emit(view)
            return view
        return QWebEngineView.createWindow(self, windowType)

WebView를 가지고 있는 WebPageWidget에서 시그널을 new tab, new window에 대한 시그널을 추가하고 연결해주자

class WebPageWidget(QWidget):
    # ...
    sig_new_tab = pyqtSignal(WebView)
    sig_new_window = pyqtSignal(WebView)
    
    def initControl(self):
        # ...
        self.setWebViewSignals()
        
    def setWebViewSignals(self):
        self._webview.loadStarted.connect(self.onWebViewLoadStarted)
        self._webview.loadProgress.connect(self.onWebViewLoadProgress)
        self._webview.loadFinished.connect(self.onWebViewLoadFinished)
        self._webview.sig_new_tab.connect(self.sig_new_tab.emit)
        self._webview.sig_new_window.connect(self.sig_new_window.emit)

브라우저 윈도우에서 new tab, new signal에 대한 이벤트 처리 구문을 추가해주자

class WebBrowserWindow(QMainWindow):
    def __init__(self, parent=None, init_url: Union[str, None] = 'about:blank'):
        # ...
        if init_url is not None:
            self.addWebPageTab(init_url)
    
    def addWebPageTab(self, url: str = 'about:blank'):
        view = WebPageWidget(parent=self, url=url)
        self.setWebPageViewSignals(view)
        self.addTabCommon(view)
    
    def addWebPageView(self, view: WebView):
        view = WebPageWidget(parent=self, view=view)
        self.setWebPageViewSignals(view)
        self.addTabCommon(view)
    
    def setWebPageViewSignals(self, view: WebPageWidget):
        view.sig_page_title.connect(partial(self.setWebPageTitle, view))
        view.sig_page_icon.connect(partial(self.setWebPageIcon, view))
        view.sig_new_tab.connect(self.addWebPageView)
        view.sig_new_window.connect(self.openNewWindow)
    
    def openNewWindow(self, view: WebView):
        newwnd = WebBrowserWindow(self, init_url=None)
        newwnd.addWebPageView(view)
        newwnd.show()

new window 이벤트는 init_url = None으로 처리하여 탭을 추가하지 않고 수동으로 전달된 인자인 WebView를 탭으로 추가하여 새로운 윈도우를 만들어 show하도록 간단하게 구현했다

 

링크 우클릭 시 QWebEngineView에 자체적으로 구현되어 있는 컨텍스트 메뉴가 나오는데, new tab 및 new window 메뉴도 있으니 테스트해보자

open line in new tab 테스트
open link in new window 테스트

3.3. 키보드 이벤트 추가

크롬은 링크 클릭 시 키보드 Ctrl 키가 눌려있으면 새로운 탭에서 링크가 로드된다

이 기능은 앞서 오버라이드한 QWebEngineView의 createWindow 메서드에서 다음과 같이 구현했다

class WebView(QWebEngineView):
    # ...
    def createWindow(self, windowType):
        if windowType == QWebEnginePage.WebBrowserTab:
            view = WebView()
            self.sig_new_tab.emit(view)
            return view
        elif windowType == QWebEnginePage.WebBrowserWindow:
            view = WebView()
            self.sig_new_window.emit(view)
            return view
        # open tab when ctrl key is pressed
        modifier = QApplication.keyboardModifiers()
        if modifier == Qt.ControlModifier:
            view = WebView()
            self.sig_new_tab.emit(view)
            return view
        return QWebEngineView.createWindow(self, windowType)

또한, 크롬의 단축키 중 몇개를 차용해서 구현해봤다

  • Ctrl+N : 새로운 윈도우 열기
  • Ctrl+T : 새로운 탭 열기
  • Ctrl+W : 현재 탭 닫기
  • F6 : 주소창으로 이동 및 전체선택
class WebPageWidget(QWidget):
    # ...
    sig_new_window = pyqtSignal(object)
    sig_close = pyqtSignal(object)
    
    def keyPressEvent(self, a0: QKeyEvent) -> None:
        modifier = QApplication.keyboardModifiers()
        if a0.key() == Qt.Key_N:
            if modifier == Qt.ControlModifier:
                self.sig_new_window.emit(None)
        elif a0.key() == Qt.Key_T:
            if modifier == Qt.ControlModifier:
                self.sig_new_tab.emit(None)
        elif a0.key() == Qt.Key_W:
            if modifier == Qt.ControlModifier:
                self.sig_close.emit(self)
        elif a0.key() == Qt.Key_F5:
            self._webview.reload()
        elif a0.key() == Qt.Key_F6:
            self._editUrl.setFocus()
            self._editUrl.selectAll()
        elif a0.key() == Qt.Key_Escape:
            self._webview.stop()
        elif a0.key() == Qt.Key_Backspace:
            self._webview.back()
class WebBrowserWindow(QMainWindow):
    def addWebPageView(self, view: Union[WebView, None]):
        if view is None:
            view = WebPageWidget(parent=self)
        else:
            view = WebPageWidget(parent=self, view=view)
        self.setWebPageViewSignals(view)
        self.addTabCommon(view)
    
        def openNewWindow(self, view: Union[WebView, None]):
        if view is None:
            newwnd = WebBrowserWindow(self)
        else:
            newwnd = WebBrowserWindow(self, init_url=None)
            newwnd.addWebPageView(view)
        newwnd.show()

    def keyPressEvent(self, a0: QKeyEvent) -> None:
        modifier = QApplication.keyboardModifiers()
        if a0.key() == Qt.Key_N:
            if modifier == Qt.ControlModifier:
                self.openNewWindow(None)
        elif a0.key() == Qt.Key_T:
            if modifier == Qt.ControlModifier:
                self.addWebPageTab()
        elif a0.key() == Qt.Key_W:
            if modifier == Qt.ControlModifier:
                curwgt = self._tabWidget.currentWidget()
                self.closeWebPageTab(curwgt)

이 밖에도 다른 기능들에 대한 단축키는 위 구현방식을 따라 입맛대로 구현하면 된다

 

3.4. 탭바 컨텍스트 메뉴 만들기

탭바를 우클릭했을 때 컨텍스트 메뉴가 디스플레이되도록 구현해보자

[Common.py]

from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QAction


def makeQAction(**kwargs):
    parent = None
    text = None
    iconPath = None
    triggered = None
    checkable = False
    checked = False
    enabled = True

    if 'parent' in kwargs.keys():
        parent = kwargs['parent']
    if 'text' in kwargs.keys():
        text = kwargs['text']
    if 'iconPath' in kwargs.keys():
        iconPath = kwargs['iconPath']
    if 'triggered' in kwargs.keys():
        triggered = kwargs['triggered']
    if 'checkable' in kwargs.keys():
        checkable = kwargs['checkable']
    if 'checked' in kwargs.keys():
        checked = kwargs['checked']
    if 'enabled' in kwargs.keys():
        enabled = kwargs['enabled']

    action = QAction(parent)
    action.setText(text)
    action.setIcon(QIcon(iconPath))
    if triggered is not None:
        action.triggered.connect(triggered)
    action.setCheckable(checkable)
    action.setChecked(checked)
    action.setEnabled(enabled)

    return action

[CustomTabWidget.py]

from PyQt5.QtCore import Qt, pyqtSignal, QSize, QPoint
from Common import makeQAction

class CustomTabWidget(QTabWidget):
    sig_add_tab = pyqtSignal()
    sig_new_window = pyqtSignal(int)
    sig_close = pyqtSignal(int)
    sig_close_others = pyqtSignal(int)
    
    def __init__(self, parent=None):
        # ...
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.showContextMenu)
    
    def showContextMenu(self, point: QPoint):
        if point.isNull():
            return
        index = self.tabBar().tabAt(point)
        menu = QMenu(self)
        menuAddtab = makeQAction(parent=self, text='Add New Tab',
                                 triggered=self.sig_add_tab.emit)
        menu.addAction(menuAddtab)
        menuNewWnd = makeQAction(parent=self, text='Move to New Window',
                                 triggered=lambda: self.sig_new_window.emit(index))
        menu.addAction(menuNewWnd)
        menu.addSeparator()
        menuCloseTab = makeQAction(parent=self, text='Close',
                                   triggered=lambda: self.sig_close.emit(index))
        menu.addAction(menuCloseTab)
        menuCloseOthers = makeQAction(parent=self, text='Close Others',
                                      triggered=lambda: self.sig_close_others.emit(index))
        menu.addAction(menuCloseOthers)
        menu.exec(self.tabBar().mapToGlobal(point))

[WebBrowserWindow.py]

class WebBrowserWindow(QMainWindow):
    # ...
    def initControl(self):
        self._tabWidget.sig_add_tab.connect(self.addWebPageTab)
        self._tabWidget.sig_new_window.connect(self.onTabNewWindow)
        self._tabWidget.sig_close.connect(self.onTabCloseView)
        self._tabWidget.sig_close_others.connect(self.onTabCloseViewOthers)
    
    def onTabNewWindow(self, index: int):
        widget = self._tabWidget.widget(index)
        self._tabWidget.removeTab(index)
        newwnd = WebBrowserWindow(self, init_url=None)
        if isinstance(widget, WebPageWidget):
            newwnd.addWebPageWidget(widget)
        newwnd.show()
    
    def onTabCloseView(self, index: int):
        view = self._tabWidget.widget(index)
        self.closeWebPageTab(view)

    def onTabCloseViewOthers(self, index: int):
        view = self._tabWidget.widget(index)
        lst = []
        for i in range(self._tabWidget.count()):
            wgt = self._tabWidget.widget(i)
            if wgt != view:
                lst.append(wgt)
        self.closeWebPageTabs(lst)

Context Menu - Add New Tab
Context Menu - Move to New Window
Context Menu - Close
Context Menu - Close Others

입맛에 따라 더 추가하고 싶은 메뉴가 있으면 showContextMenu에 액션을 추가하면 된다

(우측탭 닫기 등은 GitHub Repository 참고)

 

3.5. 탭바 길이 조절하기

웹페이지의 타이틀이 길게 나올 경우 탭바가 해당 텍스트를 전부 담기 위해 지나치게 길어지는 경우가 있다

탭바의 길이는 QTabBar의 tabSizeHint 메서드를 오버라이딩해서 구현할 수 있으므로 QTabBar 클래스를 상속한 커스터마이징 탭바를 구현하자

class CustomTabBar(QTabBar):
    def __init__(self, parent=None):
        super().__init__(parent=parent)

    def tabSizeHint(self, index: int) -> QSize:
        size = super().tabSizeHint(index)
        add_btn_width = 36
        parent_width = self.parent().width()
        if index == self.count() - 1:
            width = add_btn_width
        else:
            width_max = 240
            if (self.count() - 1) * width_max < parent_width:
                width = width_max
            else:
                width = int((parent_width - add_btn_width) / (self.count() - 1))
        return QSize(width, size.height())

탭 하나당 최대 길이는 240px로 정한 뒤, 탭 위젯이 탭들을 한 화면에 모두 담지 못할 경우 모든 탭들이 240보다 작은 길이로 동일하게 설정되도록 구현했다

class CustomTabWidget(QTabWidget):
    def __init__(self, parent=None):
        # ...
        self._tabbar = CustomTabBar(self)
        self.setTabBar(self._tabbar)

탭위젯의 탭바를 커스터마이즈한 객체로 바꾼 뒤 테스트해보면 다음과 같다

탭바 길이 조정 후

TODO: 탭바의 텍스트 정렬을 바꾸고 싶었는데, stylesheet로도 바뀌지 않는다. 탭바에 올라가는 위젯들 자체를 커스터마이즈해야 할 필요가 있다


굉장히 허접하지만 그래도 일정수준 틀은 갖춘 웹브라우저가 그럭저럭 완성되었다

다음에는 북마크, 쿠키 설정 등 기능도 구현해보고, 개발 시 필요한 기능인 Javascript 실행, 페이지소스 뷰 등 고급 기능들도 구현해보도록 한다

 

지금까지 구현한 내용은 다음 깃허브 링크에서 확인할 수 있다

https://github.com/YOGYUI/Projects/tree/rev.2/WebBrowser/python

 

GitHub - YOGYUI/Projects

Contribute to YOGYUI/Projects development by creating an account on GitHub.

github.com

 

반응형
Comments