Created
August 18, 2025 00:55
-
-
Save patx/55f7189b82b499411be603844f8df26e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # fastPATX (PyQt6) | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import webbrowser | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Optional | |
| from PyQt6 import QtCore, QtWidgets | |
| from PyQt6.QtCore import QUrl, Qt, QTimer, QTemporaryDir | |
| from PyQt6.QtGui import ( | |
| QKeySequence, | |
| QAction, | |
| QDesktopServices, | |
| QIcon, | |
| QShortcut, | |
| ) | |
| from PyQt6.QtWebEngineWidgets import QWebEngineView | |
| from PyQt6.QtWebEngineCore import ( | |
| QWebEngineProfile, | |
| QWebEngineDownloadRequest, | |
| QWebEngineSettings, | |
| QWebEnginePage, | |
| QWebEngineContextMenuRequest, | |
| QWebEngineNavigationRequest, | |
| ) | |
| APP_NAME = "fastPATX" | |
| BOOKMARKS_FILE = Path("fastpatx_faves.txt") | |
| # --- MIME helpers --- | |
| VIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/") | |
| VIEWABLE_MIME_EXACT = { | |
| "application/pdf", | |
| "text/plain", | |
| "text/html", | |
| "application/xhtml+xml", | |
| "application/xml", | |
| } | |
| # Extensions we treat as binary (trigger download instead of navigate) | |
| BINARY_EXTS = { | |
| ".zip", ".xz", ".gz", ".tgz", ".bz2", ".7z", ".rar", | |
| ".iso", ".img", ".dmg", | |
| ".exe", ".msi", ".apk", ".deb", ".rpm", ".pkg", | |
| ".bin", ".tar", ".cab", | |
| ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", | |
| ".psd", ".ai", | |
| } | |
| def is_viewable_mime(mime: str) -> bool: | |
| if not mime: | |
| return False | |
| mime = mime.lower() | |
| if mime in VIEWABLE_MIME_EXACT: | |
| return True | |
| return any(mime.startswith(p) for p in VIEWABLE_MIME_PREFIXES) | |
| def themed_icon(*names: str, fallback: QIcon | None = None) -> QIcon: | |
| for n in names: | |
| ic = QIcon.fromTheme(n) | |
| if not ic.isNull(): | |
| return ic | |
| return fallback or QIcon() | |
| # --- Download state enums (Qt6 variants) --- | |
| try: | |
| DL_COMPLETED = QWebEngineDownloadRequest.DownloadState.Completed | |
| DL_CANCELLED = QWebEngineDownloadRequest.DownloadState.Cancelled | |
| DL_INTERRUPTED = QWebEngineDownloadRequest.DownloadState.Interrupted | |
| except AttributeError: | |
| DL_COMPLETED = QWebEngineDownloadRequest.DownloadState.DownloadCompleted | |
| DL_CANCELLED = QWebEngineDownloadRequest.DownloadState.DownloadCancelled | |
| DL_INTERRUPTED = QWebEngineDownloadRequest.DownloadState.DownloadInterrupted | |
| # =================== Downloads Tab =================== | |
| @dataclass | |
| class DownloadRow: | |
| request: QWebEngineDownloadRequest | |
| row: int | |
| filepath: Optional[Path] = None | |
| done: bool = False | |
| ok: bool = False | |
| class DownloadsTab(QtWidgets.QWidget): | |
| def __init__(self, parent: "MainWindow"): | |
| super().__init__(parent) | |
| self.main: MainWindow = parent | |
| self.table = QtWidgets.QTableWidget(0, 6, self) | |
| self.table.setHorizontalHeaderLabels(["File", "Size", "Progress", "Status", "Path", "Actions"]) | |
| self.table.horizontalHeader().setStretchLastSection(True) | |
| self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) | |
| self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) | |
| self.table.verticalHeader().setVisible(False) | |
| layout = QtWidgets.QVBoxLayout(self) | |
| layout.setContentsMargins(6, 6, 6, 6) | |
| layout.addWidget(self.table) | |
| self.rows: dict[QWebEngineDownloadRequest, DownloadRow] = {} | |
| @staticmethod | |
| def _fmt_size(n: int) -> str: | |
| if n <= 0: | |
| return "—" | |
| mb = n / (1024 * 1024) | |
| if mb >= 1: | |
| return f"{mb:.2f} MB" | |
| kb = n / 1024 | |
| return f"{kb:.0f} KB" | |
| def add_download(self, req: QWebEngineDownloadRequest, filepath: Optional[Path] = None): | |
| r = self.table.rowCount() | |
| self.table.insertRow(r) | |
| name = req.downloadFileName() or "download" | |
| size_text = self._fmt_size(req.totalBytes()) | |
| self.table.setItem(r, 0, QtWidgets.QTableWidgetItem(name)) | |
| self.table.setItem(r, 1, QtWidgets.QTableWidgetItem(size_text)) | |
| prog = QtWidgets.QProgressBar() | |
| prog.setValue(0) | |
| self.table.setCellWidget(r, 2, prog) | |
| self.table.setItem(r, 3, QtWidgets.QTableWidgetItem("Starting…")) | |
| self.table.setItem(r, 4, QtWidgets.QTableWidgetItem(str(filepath) if filepath else "—")) | |
| btn_open = QtWidgets.QPushButton("Open") | |
| btn_open.setEnabled(False) | |
| btn_reveal = QtWidgets.QPushButton("Reveal") | |
| btn_reveal.setEnabled(False) | |
| btn_cancel = QtWidgets.QPushButton("Cancel") | |
| btn_cancel.setEnabled(True) | |
| wrap = QtWidgets.QWidget() | |
| h = QtWidgets.QHBoxLayout(wrap) | |
| h.setContentsMargins(0, 0, 0, 0) | |
| h.addWidget(btn_open) | |
| h.addWidget(btn_reveal) | |
| h.addWidget(btn_cancel) | |
| self.table.setCellWidget(r, 5, wrap) | |
| row = DownloadRow(request=req, row=r, filepath=filepath) | |
| self.rows[req] = row | |
| def update_progress(): | |
| rec = req.receivedBytes() | |
| total = req.totalBytes() | |
| pct = int(rec * 100 / total) if total > 0 else 0 | |
| prog.setValue(pct) | |
| self.table.item(r, 1).setText(self._fmt_size(total)) | |
| self.table.item(r, 3).setText(f"Downloading… {pct}%" if total > 0 else "Downloading…") | |
| def on_state_changed(new_state): | |
| if new_state == DL_COMPLETED: | |
| row.done = True | |
| row.ok = True | |
| prog.setValue(100) | |
| self.table.item(r, 3).setText("Completed") | |
| full = Path(req.downloadDirectory()) / req.downloadFileName() | |
| row.filepath = full | |
| self.table.setItem(r, 4, QtWidgets.QTableWidgetItem(str(full))) | |
| btn_open.setEnabled(True) | |
| btn_reveal.setEnabled(True) | |
| btn_cancel.setEnabled(False) | |
| elif new_state in (DL_CANCELLED, DL_INTERRUPTED): | |
| row.done = True | |
| row.ok = False | |
| self.table.item(r, 3).setText("Cancelled" if new_state == DL_CANCELLED else "Interrupted") | |
| btn_cancel.setEnabled(False) | |
| btn_open.clicked.connect(lambda: self._open_file(self.rows[req].filepath)) | |
| btn_reveal.clicked.connect(lambda: self._reveal_file(self.rows[req].filepath)) | |
| btn_cancel.clicked.connect(lambda: req.cancel()) | |
| req.receivedBytesChanged.connect(update_progress) | |
| req.totalBytesChanged.connect(update_progress) | |
| req.stateChanged.connect(on_state_changed) | |
| update_progress() | |
| self.table.resizeColumnsToContents() | |
| def _open_file(self, path: Optional[Path]): | |
| if not path: | |
| return | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) | |
| def _reveal_file(self, path: Optional[Path]): | |
| if not path: | |
| return | |
| folder = path.parent | |
| if sys.platform.startswith("linux"): | |
| webbrowser.open(f"file://{folder}") | |
| elif sys.platform == "darwin": | |
| os.system(f'open -R "{path}"') | |
| elif sys.platform.startswith("win"): | |
| os.startfile(folder) # type: ignore | |
| # =================== Browser View + Page =================== | |
| class CustomPage(QWebEnginePage): | |
| """Interceptors to prevent navigation to likely-binary links and trigger download instead.""" | |
| def __init__(self, profile: QWebEngineProfile, parent=None): | |
| super().__init__(profile, parent) | |
| self.navigationRequested.connect(self._on_navigation_requested) | |
| def _on_navigation_requested(self, req: QWebEngineNavigationRequest): | |
| if req.navigationType() != QWebEngineNavigationRequest.NavigationType.LinkClicked: | |
| return | |
| url = req.url() | |
| path = Path(url.path() or "") | |
| ext = path.suffix.lower() | |
| if ext in BINARY_EXTS: | |
| req.reject() # Block navigation immediately | |
| win: MainWindow = self.view().window() | |
| suggested = path.name or "download" | |
| target, _ = QtWidgets.QFileDialog.getSaveFileName(win, "Save File", suggested, "All Files (*)") | |
| if target: | |
| dl = self.download(url, target) | |
| if dl is not None: | |
| win.ensure_downloads_tab() | |
| win._downloads_tab.add_download(dl, Path(target)) | |
| win.showDownloads() | |
| # Ensure no navigation occurs | |
| view = self.view() | |
| if view and view.url() == url: | |
| view.stop() | |
| if view.history().canGoBack(): | |
| view.back() | |
| else: | |
| view.setUrl(QUrl("about:blank")) | |
| class BrowserView(QWebEngineView): | |
| openLinkInNewTab = QtCore.pyqtSignal(QUrl, bool) | |
| saveLinkAs = QtCore.pyqtSignal(QUrl) | |
| def __init__(self, parent=None): | |
| super().__init__(parent) | |
| profile = QWebEngineProfile.defaultProfile() | |
| self.setPage(CustomPage(profile, self)) | |
| def contextMenuEvent(self, event): | |
| req: QWebEngineContextMenuRequest = self.lastContextMenuRequest() | |
| menu = QtWidgets.QMenu(self) | |
| link_url = req.linkUrl() | |
| if link_url.isValid(): | |
| act_open = menu.addAction("Open Link in New Tab") | |
| act_open.triggered.connect(lambda: self.openLinkInNewTab.emit(link_url, False)) | |
| act_open_bg = menu.addAction("Open Link in Background Tab") | |
| act_open_bg.triggered.connect(lambda: self.openLinkInNewTab.emit(link_url, True)) | |
| act_copy = menu.addAction("Copy Link Address") | |
| act_copy.triggered.connect(lambda: QtWidgets.QApplication.clipboard().setText(link_url.toString())) | |
| act_save = menu.addAction("Save Link As…") | |
| act_save.triggered.connect(lambda: self.saveLinkAs.emit(link_url)) | |
| menu.addSeparator() | |
| page: QWebEnginePage = self.page() | |
| def add_web_action(web_action, text=None): | |
| act = page.action(web_action) | |
| if text: | |
| act.setText(text) | |
| if act.isEnabled(): | |
| menu.addAction(act) | |
| add_web_action(QWebEnginePage.WebAction.Back, "Back") | |
| add_web_action(QWebEnginePage.WebAction.Forward, "Forward") | |
| add_web_action(QWebEnginePage.WebAction.Reload, "Reload") | |
| menu.addSeparator() | |
| add_web_action(QWebEnginePage.WebAction.Copy, "Copy") | |
| add_web_action(QWebEnginePage.WebAction.Cut, "Cut") | |
| add_web_action(QWebEnginePage.WebAction.Paste, "Paste") | |
| add_web_action(QWebEnginePage.WebAction.SelectAll, "Select All") | |
| menu.addSeparator() | |
| win = self.window() | |
| menu.addAction("Downloads", win.showDownloads) | |
| menu.addAction("View Source", win.viewSrc) | |
| menu.addAction("Bookmark Page", win.bookmarkCurrent) | |
| menu.addAction("Your Bookmarks", win.showBookmarks) | |
| menu.addAction("Save Page HTML", win.savePageHtml) | |
| menu.addSeparator() | |
| menu.addAction("About", win.About) | |
| menu.addAction("Help", win.Help) | |
| menu.exec(event.globalPos()) | |
| # =================== Tab widgets =================== | |
| class PatxTab(QtWidgets.QWidget): | |
| SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] | |
| def __init__(self, index: int, parent: "MainWindow" | None = None): | |
| super().__init__(parent) | |
| self.mainUi: MainWindow = parent | |
| self.index = index | |
| self.browser = BrowserView(self) | |
| self.browser.urlChanged.connect(self._updateLineEdit) | |
| self.browser.loadStarted.connect(self._loadStarted) | |
| self.browser.loadFinished.connect(self._loadFinished) | |
| self.browser.titleChanged.connect(self._updateTabTitle) | |
| self.browser.openLinkInNewTab.connect(self._open_link_in_new_tab) | |
| self.browser.saveLinkAs.connect(self._save_link_as) | |
| self.browser.iconChanged.connect(self._icon_changed) | |
| layout = QtWidgets.QHBoxLayout(self) | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| layout.addWidget(self.browser) | |
| self.current_url = QUrl("https://www.google.com/") | |
| self.browser.setUrl(self.current_url) | |
| self._base_title = "(untitled)" | |
| self._spin_idx = 0 | |
| self._spinner = QTimer(self) | |
| self._spinner.setInterval(90) | |
| self._spinner.timeout.connect(self._tick_spinner) | |
| def go(self, url: QUrl): | |
| self.current_url = url | |
| self.browser.setUrl(url) | |
| def _loadStarted(self): | |
| self._spin_idx = 0 | |
| if not self._spinner.isActive(): | |
| self._spinner.start() | |
| def _loadFinished(self, ok: bool): | |
| if self._spinner.isActive(): | |
| self._spinner.stop() | |
| self.mainUi.tabs.setTabText(self.index, self._base_title or "(untitled)") | |
| if not ok: | |
| self.browser.setHtml("<p>This page could not be rendered/loaded.</p>") | |
| def _tick_spinner(self): | |
| frame = PatxTab.SPINNER_FRAMES[self._spin_idx % len(PatxTab.SPINNER_FRAMES)] | |
| self._spin_idx += 1 | |
| if not self.mainUi.is_plus_tab(self.index): | |
| self.mainUi.tabs.setTabText(self.index, f"{frame} {self._base_title}") | |
| def _updateTabTitle(self, title: str): | |
| self._base_title = title or "(untitled)" | |
| if not self._spinner.isActive(): | |
| self.mainUi.tabs.setTabText(self.index, self._base_title) | |
| def _updateLineEdit(self, url: QUrl): | |
| self.current_url = url | |
| if self.index == self.mainUi.tabs.currentIndex(): | |
| self.mainUi.address.setText(url.toString()) | |
| def _open_link_in_new_tab(self, url: QUrl, background: bool): | |
| self.mainUi.open_url_in_new_tab(url, background=background) | |
| def _save_link_as(self, url: QUrl): | |
| suggested = Path(url.path()).name or "download" | |
| target, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Link As", suggested, "All Files (*)") | |
| if not target: | |
| return | |
| path = Path(target) | |
| req = self.browser.page().download(url, path.name) | |
| if req is not None: | |
| self.mainUi.ensure_downloads_tab() | |
| self.mainUi._downloads_tab.add_download(req, path) | |
| self.mainUi.showDownloads() | |
| def _icon_changed(self, icon: QIcon): | |
| if self.index == self.mainUi.tabs.currentIndex(): | |
| self.mainUi.updateFavicon(icon) | |
| # =================== Main Window =================== | |
| class MainWindow(QtWidgets.QMainWindow): | |
| PLUS_TAB_TEXT = "+" | |
| def __init__(self): | |
| super().__init__() | |
| self.setWindowTitle(APP_NAME) | |
| self.resize(1200, 800) | |
| self.temp_dir = QTemporaryDir() | |
| if not self.temp_dir.isValid(): | |
| QtWidgets.QMessageBox.critical(self, APP_NAME, "Failed to create temporary directory.") | |
| central = QtWidgets.QWidget(self) | |
| v = QtWidgets.QVBoxLayout(central) | |
| v.setContentsMargins(6, 6, 6, 6) | |
| v.setSpacing(6) | |
| # Top bar | |
| top = QtWidgets.QHBoxLayout() | |
| top.setSpacing(6) | |
| style = self.style() | |
| icon_back = style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowBack) | |
| icon_forward = style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowForward) | |
| icon_home = style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DirHomeIcon) | |
| icon_globe = themed_icon("internet-web-browser", fallback=style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DesktopIcon)) | |
| self.btnBack = QtWidgets.QToolButton() | |
| self.btnBack.setIcon(icon_back) | |
| self.btnBack.clicked.connect(self.goBack) | |
| self.btnForward = QtWidgets.QToolButton() | |
| self.btnForward.setIcon(icon_forward) | |
| self.btnForward.clicked.connect(self.goForward) | |
| self.btnHome = QtWidgets.QToolButton() | |
| self.btnHome.setIcon(icon_home) | |
| self.btnHome.clicked.connect(self.goHome) | |
| self.address = QtWidgets.QLineEdit() | |
| self.address.returnPressed.connect(self.addressGo) | |
| self.addrFaviconAct = self.address.addAction(icon_globe, QtWidgets.QLineEdit.ActionPosition.LeadingPosition) | |
| self.addrGoAct = self.address.addAction(icon_forward, QtWidgets.QLineEdit.ActionPosition.TrailingPosition) | |
| self.addrGoAct.triggered.connect(self.addressGo) | |
| top.addWidget(self.btnBack) | |
| top.addWidget(self.btnForward) | |
| top.addWidget(self.btnHome) | |
| top.addWidget(self.address, 1) | |
| v.addLayout(top) | |
| # Tabs | |
| self.tabs = QtWidgets.QTabWidget() | |
| self.tabs.setDocumentMode(True) | |
| self.tabs.setTabsClosable(True) | |
| self.tabs.tabCloseRequested.connect(self.closeTab) | |
| self.tabs.tabBarClicked.connect(self._on_tabbar_clicked) | |
| self.tabs.currentChanged.connect(self._tabChanged) | |
| v.addWidget(self.tabs) | |
| self.setCentralWidget(central) | |
| # Downloads tab handle | |
| self._downloads_tab: Optional[DownloadsTab] = None | |
| self._downloads_tab_index: int = -1 | |
| # First real tab and downloads tab | |
| self.newTab() | |
| self.ensure_downloads_tab() | |
| self.ensure_plus_tab() | |
| # Ensure close buttons exist then sync visibility | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| # Profile & downloads | |
| profile = QWebEngineProfile.defaultProfile() | |
| try: | |
| profile.settings().setAttribute(QWebEngineSettings.WebAttribute.PdfViewerEnabled, True) | |
| except Exception: | |
| pass | |
| profile.downloadRequested.connect(self.on_downloadRequested) | |
| QtWidgets.QApplication.instance().setWindowIcon(themed_icon("applications-internet")) | |
| # Shortcuts | |
| QShortcut(QKeySequence("Ctrl+T"), self, activated=self.newTab) | |
| QShortcut(QKeySequence("Ctrl+W"), self, activated=lambda: self.closeTab(self.tabs.currentIndex())) | |
| QShortcut(QKeySequence("Alt+Left"), self, activated=self.goBack) | |
| QShortcut(QKeySequence("Alt+Right"), self, activated=self.goForward) | |
| QShortcut(QKeySequence("Ctrl+H"), self, activated=self.goHome) | |
| QShortcut(QKeySequence("Ctrl+U"), self, activated=self.viewSrc) | |
| QShortcut(QKeySequence("Ctrl+D"), self, activated=self.savePageHtml) | |
| # ---- Close button logic ---- | |
| def _force_create_close_buttons(self): | |
| self.tabs.setTabsClosable(False) | |
| self.tabs.setTabsClosable(True) | |
| def browser_tab_count(self) -> int: | |
| return sum(1 for i in range(self.tabs.count()) if isinstance(self.tabs.widget(i), PatxTab)) | |
| def update_close_buttons(self): | |
| """Show 'X' on browser tabs if >1 browser tab; always on downloads; hide on '+' tab.""" | |
| tb = self.tabs.tabBar() | |
| bcount = self.browser_tab_count() | |
| for i in range(self.tabs.count()): | |
| is_plus = self.is_plus_tab(i) | |
| is_dls = i == self._downloads_tab_index | |
| btn = tb.tabButton(i, QtWidgets.QTabBar.ButtonPosition.RightSide) | |
| if btn: | |
| btn.setVisible(not is_plus and (is_dls or bcount > 1)) | |
| # ---- Downloads tab helpers ---- | |
| def ensure_downloads_tab(self): | |
| if self._downloads_tab is None or self._downloads_tab_index < 0 or self._downloads_tab_index >= self.tabs.count(): | |
| self._downloads_tab = DownloadsTab(self) | |
| insert_at = self.tabs.count() | |
| if self.tabs.count() and self.is_plus_tab(self.tabs.count() - 1): | |
| insert_at -= 1 | |
| self._downloads_tab_index = self.tabs.insertTab(insert_at, self._downloads_tab, "Downloads") | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| return self._downloads_tab_index | |
| def showDownloads(self): | |
| idx = self.ensure_downloads_tab() | |
| self.tabs.setCurrentIndex(idx) | |
| self.update_close_buttons() | |
| # ---- "+" tab helpers ---- | |
| def ensure_plus_tab(self): | |
| if self.tabs.count() == 0 or self.tabs.tabText(self.tabs.count() - 1) != self.PLUS_TAB_TEXT: | |
| dummy = QtWidgets.QWidget() | |
| idx = self.tabs.addTab(dummy, self.PLUS_TAB_TEXT) | |
| self.tabs.tabBar().setTabButton(idx, QtWidgets.QTabBar.ButtonPosition.RightSide, None) | |
| def is_plus_tab(self, index: int) -> bool: | |
| return 0 <= index < self.tabs.count() and self.tabs.tabText(index) == self.PLUS_TAB_TEXT | |
| def _on_tabbar_clicked(self, index: int): | |
| if self.is_plus_tab(index): | |
| self.newTab() | |
| # ---- Tabs ---- | |
| def newTab(self, url: QUrl | None = None, background: bool = False): | |
| insert_at = self.tabs.count() | |
| if self.tabs.count() > 0 and self.is_plus_tab(self.tabs.count() - 1): | |
| insert_at -= 1 | |
| tab = PatxTab(insert_at, self) | |
| idx = self.tabs.insertTab(insert_at, tab, "(untitled)") | |
| if not background: | |
| self.tabs.setCurrentIndex(idx) | |
| if url: | |
| tab.go(url) | |
| self.ensure_plus_tab() | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| def closeTab(self, index: int): | |
| if self.is_plus_tab(index): | |
| return | |
| w = self.tabs.widget(index) | |
| if isinstance(w, PatxTab) and self.browser_tab_count() <= 1: | |
| return | |
| if w is self._downloads_tab: | |
| self._downloads_tab = None | |
| self._downloads_tab_index = -1 | |
| if w: | |
| self.tabs.removeTab(index) | |
| w.deleteLater() | |
| self.ensure_plus_tab() | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| def _tabChanged(self, idx: int): | |
| w = self.tabs.widget(idx) | |
| if isinstance(w, PatxTab): | |
| self.address.setText(w.current_url.toString()) | |
| self.updateFavicon(w.browser.icon()) | |
| else: | |
| self.address.clear() | |
| self.updateFavicon(QIcon()) | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| # ---- Convenience ---- | |
| def currentBrowser(self) -> Optional["BrowserView"]: | |
| w = self.tabs.currentWidget() | |
| return w.browser if isinstance(w, PatxTab) else None | |
| # ---- Navigation ---- | |
| def goBack(self): | |
| b = self.currentBrowser() | |
| if b: | |
| b.back() | |
| def goForward(self): | |
| b = self.currentBrowser() | |
| if b: | |
| b.forward() | |
| def goHome(self): | |
| b = self.currentBrowser() | |
| if b: | |
| b.setUrl(QUrl("https://www.google.com/")) | |
| def addressGo(self): | |
| raw = self.address.text().strip() | |
| if not raw: | |
| return | |
| if "://" not in raw and "." not in raw: | |
| url = QUrl(f"https://www.google.com/search?hl=en&safe=off&q={QUrl.fromPercentEncoding(raw.encode())}") | |
| else: | |
| url = QUrl.fromUserInput(raw) | |
| w = self.tabs.currentWidget() | |
| if isinstance(w, PatxTab): | |
| w.go(url) | |
| else: | |
| self.newTab(url) | |
| def open_url_in_new_tab(self, url: QUrl, background: bool = False): | |
| self.newTab(url, background=background) | |
| def updateFavicon(self, icon: QIcon): | |
| if icon.isNull(): | |
| icon = themed_icon("internet-web-browser", fallback=self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DesktopIcon)) | |
| self.addrFaviconAct.setIcon(icon) | |
| # ---- App actions (invoked from context menu) ---- | |
| def viewSrc(self): | |
| b = self.currentBrowser() | |
| if not b: | |
| return | |
| def show(html: str): | |
| dlg = QtWidgets.QDialog(self) | |
| dlg.setWindowTitle("Page Source") | |
| txt = QtWidgets.QPlainTextEdit(dlg) | |
| txt.setPlainText(html) | |
| txt.setReadOnly(True) | |
| ok = QtWidgets.QPushButton("&OK", dlg) | |
| ok.clicked.connect(dlg.accept) | |
| lay = QtWidgets.QVBoxLayout(dlg) | |
| lay.addWidget(txt) | |
| lay.addWidget(ok) | |
| dlg.resize(900, 600) | |
| dlg.exec() | |
| b.page().toHtml(show) | |
| def bookmarkCurrent(self): | |
| url_text = self.address.text().strip() | |
| if not url_text: | |
| return | |
| existing = BOOKMARKS_FILE.read_text(encoding="utf-8") if BOOKMARKS_FILE.exists() else "" | |
| link = f'<a href="{QUrl.fromUserInput(url_text).toString()}">{url_text}</a><br>' | |
| BOOKMARKS_FILE.write_text(existing + link, encoding="utf-8") | |
| QtWidgets.QMessageBox.information(self, APP_NAME, "The page has been bookmarked.") | |
| def showBookmarks(self): | |
| html = BOOKMARKS_FILE.read_text(encoding="utf-8") if BOOKMARKS_FILE.exists() else "<p>No bookmarks yet.</p>" | |
| self.open_url_in_new_tab(QUrl("about:blank")) | |
| w = self.tabs.currentWidget() | |
| if isinstance(w, PatxTab): | |
| w.browser.setHtml(html) | |
| self._force_create_close_buttons() | |
| self.update_close_buttons() | |
| def savePageHtml(self): | |
| b = self.currentBrowser() | |
| if not b: | |
| return | |
| dest, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Page HTML", "page.html", "HTML Files (*.html);;All Files (*)") | |
| if not dest: | |
| return | |
| def save(html: str): | |
| try: | |
| Path(dest).write_text(html, encoding="utf-8") | |
| QtWidgets.QMessageBox.information(self, APP_NAME, f"Saved page HTML to:\n{dest}") | |
| except Exception as e: | |
| QtWidgets.QMessageBox.critical(self, "Error", f"Failed to save:\n{e}") | |
| b.page().toHtml(save) | |
| def About(self): | |
| QtWidgets.QMessageBox.about( | |
| self, | |
| APP_NAME, | |
| ('<p><b>fastPATX</b></p>' | |
| '<p>Owner: Harrison Erd</p>' | |
| '<p>License: Zlib</p>' | |
| '<p>PyQt6 + Qt WebEngine</p>') | |
| ) | |
| def Help(self): | |
| QtWidgets.QMessageBox.information( | |
| self, | |
| APP_NAME, | |
| ("Shortcuts:\n" | |
| " Back: Alt+Left Forward: Alt+Right New Tab: Ctrl+T Close Tab: Ctrl+W\n" | |
| " Home: Ctrl+H View Source: Ctrl+U Save HTML: Ctrl+D") | |
| ) | |
| # ---- Downloads (profile-level) ---- | |
| @QtCore.pyqtSlot(QWebEngineDownloadRequest) | |
| def on_downloadRequested(self, download: QWebEngineDownloadRequest): | |
| mime = (download.mimeType() or "").lower() | |
| url = download.url() | |
| viewable = is_viewable_mime(mime) | |
| suggested = download.downloadFileName() or Path(url.path()).name or "download" | |
| # Prevent navigation to the download URL | |
| cur = self.currentBrowser() | |
| if cur and cur.url() == url: | |
| cur.stop() | |
| if cur.history().canGoBack(): | |
| cur.back() | |
| else: | |
| cur.setUrl(QUrl("about:blank")) | |
| if viewable: | |
| msg = QtWidgets.QMessageBox(self) | |
| msg.setText(f"The server is offering to download \"{suggested}\". Would you like to open it inline or save it?") | |
| open_btn = msg.addButton("Open", QtWidgets.QMessageBox.ButtonRole.AcceptRole) | |
| save_btn = msg.addButton("Save", QtWidgets.QMessageBox.ButtonRole.AcceptRole) | |
| cancel_btn = msg.addButton("Cancel", QtWidgets.QMessageBox.ButtonRole.RejectRole) | |
| msg.exec() | |
| if msg.clickedButton() == cancel_btn: | |
| download.cancel() | |
| return | |
| if msg.clickedButton() == open_btn: | |
| download.setDownloadDirectory(self.temp_dir.path()) | |
| download.accept() | |
| def on_state_changed(state): | |
| if state == DL_COMPLETED: | |
| full = Path(download.downloadDirectory()) / download.downloadFileName() | |
| file_url = QUrl.fromLocalFile(str(full)) | |
| self.open_url_in_new_tab(file_url) | |
| download.stateChanged.connect(on_state_changed) | |
| return | |
| target, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save File", suggested, "All Files (*)") | |
| if not target: | |
| download.cancel() | |
| return | |
| chosen_path = Path(target) | |
| download.setDownloadDirectory(str(chosen_path.parent)) | |
| download.setDownloadFileName(chosen_path.name) | |
| self.ensure_downloads_tab() | |
| self._downloads_tab.add_download(download, chosen_path) | |
| self.showDownloads() | |
| download.accept() | |
| # =================== App bootstrap =================== | |
| def main(): | |
| QtWidgets.QApplication.setApplicationName(APP_NAME) | |
| app = QtWidgets.QApplication(sys.argv) | |
| w = MainWindow() | |
| w.show() | |
| sys.exit(app.exec()) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment