Web Browser

A privacy-first Windows browser with profiles, kids mode, vertical tabs, a command palette, and a built-in password manager.

Version 1.1.0 Updated Nov 29, 2025 Size ~210 MB Profiles: Default • Private • Kids

Why you’ll like it

Three profiles in one

Switch between Default, Private, and Kids modes with separate profiles and strict privacy rules.

Vertical or horizontal tabs

Toggle a clean vertical tab sidebar with hover-expand, or keep the classic horizontal strip.

Built-in password manager

Securely store credentials with the system keyring and fill them into login forms in one click.

Workspaces & session restore

Save your current tabs as named workspaces and optionally restore your last session on startup.

Command palette

Open the palette to jump tabs, save workspaces, or run browser actions without leaving the keyboard.

Kids-safe browsing

Kids profile uses a strict whitelist and blocks file URLs and common trackers by default.

Preview of profiles & privacy

Profile: Default
  • Persistent cookies and disk cache
  • Tracker blocking (Max / Moderate / Off)

Profile: Private
  • No persistent cookies
  • Memory-only cache
  • Tracker blocking enabled

Profile: Kids
  • Only whitelisted learning sites
  • File URLs blocked
  • Tracker blocking forced ON
          

How it works

1
Launch the browser

Run the EXE, pick a profile (Default, Private, or Kids), and open a few of your everyday sites.

2
Customize your layout

Switch between horizontal and vertical tabs, choose dark or light theme, and tune privacy level.

3
Save a workspace

Use the menu or command palette to save your current tab set and quickly restore it later.

FAQ

Does it use my system keyring?

Yes. Passwords are stored via the OS keyring using the keyring library, not in plain text files.

Can I see which privacy level is active?

Yes—the status bar shows the current profile and privacy level (Max, Moderate, or Off).

What does Kids mode block?

Kids mode blocks non-whitelisted domains, file URLs, and common tracking/analytics hosts.

Is it open source?

The full Python source is available below. Click “View Source Code” to inspect or copy it.

Download options

Installer (.exe)
Version 1.1.0 Updated 2025-11-29 Size ~210 MB

Hotkeys (example)

Ctrl + T

Open a new tab with your current profile.

Ctrl + L

Focus the address bar to type or search.

Ctrl + Shift + P

Open the tab search dialog when you have lots of tabs.

System requirements

  • Windows 10 or later (64-bit)
  • ~80 MB free space
  • Internet connection for browsing (app itself works offline)
Want more tools? Explore all free utilities on the home page.

← Back to Home

Source Code



#!/usr/bin/env python
# python -m venv venv
# .\venv\Scripts\activate
# pip install PyMuPDF keyring python-docx PyPDF2 PyQt6-WebEngine PyQt6 PyQtWebEngine psutil openpyxl pyinstaller
# pyinstaller --onefile --noconsole --icon=browser.ico --add-data "browser.ico;." "Web Browser.py"
import json, re, os, psutil, keyring, winreg, sys, html, fitz, csv, openpyxl
from datetime import datetime, timedelta
from pathlib import Path
import docx as docx_lib
from PyPDF2 import PdfReader
from urllib.parse import urlparse
from PyQt6.QtCore import (
    Qt, QUrl, QTimer, pyqtSignal, QSize, QEvent,
    QPropertyAnimation, QPoint, QStringListModel, QRect, QSizeF
)
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QToolBar, QLineEdit, QTabWidget,
    QStatusBar, QLabel, QDialog, QVBoxLayout, QHBoxLayout,
    QListWidget, QListWidgetItem, QPushButton, QComboBox,
    QInputDialog, QToolButton, QMenu, QWidget, QSizePolicy,
    QRubberBand, QStyle, QFileDialog, QCheckBox, QPlainTextEdit,
    QCompleter, QAbstractItemView, QTextEdit, QFontComboBox,
    QSpinBox, QMessageBox, QColorDialog, QFrame, QScrollArea,
    QDockWidget, QTableWidget, QTableWidgetItem, QHeaderView,
)
from PyQt6.QtGui import (
    QIcon, QDesktopServices, QAction, QTextListFormat,
    QTextCharFormat, QTextCursor, QFont, QTextBlockFormat,
    QColor, QPainter, QPen, QImage, QActionGroup,
    QTextDocument, QTextImageFormat,
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import (
    QWebEnginePage, QWebEngineProfile, QWebEngineSettings,
    QWebEngineUrlRequestInterceptor, QWebEngineDownloadRequest
)
SEARCH_ENGINE_URL = "https://duckduckgo.com/?q={}"
DEFAULT_HOME = "https://start.duckduckgo.com/"
KIDS_HOME = "https://kids.nationalgeographic.com/"
CONFIG_DIR = Path.home() / ".webbrowser"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
WORKSPACES_FILE = CONFIG_DIR / "workspaces.json"
LAST_SESSION_KEY = "_last_session_autosave"
PASSWORD_INDEX_FILE = CONFIG_DIR / "password_index.json"
HISTORY_FILE = CONFIG_DIR / "history.json"
SETTINGS_FILE = CONFIG_DIR / "settings.json"
DARK_STYLESHEET = """
QMainWindow{background-color:#05040a;color:#fdf7ff;}
QToolBar#titleBar{background-color:#070213;color:#fdf7ff;padding:0 8px;border-bottom:1px solid #2d1b4f;}
QToolBar#titleBar QLabel{color:#fdf7ff;font-weight:600;}
QToolBar#navBar{background-color:#0a0618;spacing:6px;padding:4px 8px;border-bottom:1px solid #312055;}
QToolBar#navBar QLabel{color:#fdf7ff;font-weight:600;}
QLineEdit#addressBar{background-color:#11041f;color:#fdf7ff;border-radius:14px;padding:6px 10px;border:1px solid #7c3aed;}
QLineEdit#addressBar:focus{border:1px solid #48ffa8;}
QTabWidget::pane{border-top:1px solid #2d1b4f;}
QTabBar::tab{background-color:#120825;color:#e5d0ff;padding:6px 12px;margin:2px;border-radius:8px;}
QTabBar::tab:selected{background-color:#a855f7;color:#05000c;}
QTabBar::tab:hover{background-color:#6d28d9;color:#ffffff;}
QStatusBar{background-color:#05040a;border-top:1px solid #2d1b4f;}
QStatusBar QLabel{color:#c4b5fd;}
QListWidget{background-color:#080316;color:#fdf7ff;border:1px solid #271042;}
QListWidget#verticalTabList{border-right:1px solid #31185f;border-top:none;border-bottom:none;}
QListWidget#verticalTabList::item{padding:6px 10px;margin:2px 4px;border-radius:6px;}
QListWidget#verticalTabList::item:selected{background-color:#a855f7;color:#05000c;}
QListWidget#verticalTabList::item:hover{background-color:#6d28d9;color:#ffffff;}
QDialog{background-color:#05000c;color:#fdf7ff;}
QDialog QLabel{color:#fdf7ff;}
QPushButton{background-color:#a855f7;color:#05000c;border-radius:8px;padding:6px 12px;border:none;}
QPushButton:hover{background-color:#c084fc;}
QComboBox{background-color:#0b001a;color:#fdf7ff;border-radius:8px;padding:2px 6px;border:1px solid #3b1a64;}
QComboBox QAbstractItemView{background-color:#0b001a;color:#fdf7ff;selection-background-color:#3b1a64;}
QToolButton{background-color:transparent;color:#c4b5fd;padding:4px 6px;border-radius:6px;}
QToolButton::menu-indicator{image:none;}
QToolButton:hover{background-color:#1b053b;color:#ffffff;}
QMenu{background-color:#05000c;color:#fdf7ff;border:1px solid:#271042;}
QMenu::item:selected{background-color:#3b1a64;}
"""
LIGHT_STYLESHEET = """
QMainWindow{background-color:#f3f4f6;color:#111827;}
QToolBar#navBar{background-color:#e5e7eb;color:#111827;padding:4px 8px;border-bottom:1px solid:#d1d5db;spacing:6px;}
QToolBar#navBar QLabel{color:#111827;font-weight:600;}
QLineEdit#addressBar{background-color:#ffffff;color:#111827;border-radius:14px;padding:6px 10px;border:1px solid:#d1d5db;}
QLineEdit#addressBar:focus{border:1px solid:#2563eb;}
QTabWidget::pane{border-top:1px solid:#d1d5db;}
QTabBar::tab{background-color:#e5e7eb;color:#111827;padding:5px 10px;margin:2px;border-radius:8px;}
QTabBar::tab:selected{background-color:#ffffff;color:#111827;}
QTabBar::tab:hover{background-color:#e5e7eb;}
QStatusBar{background-color:#e5e7eb;border-top:1px solid:#d1d5db;}
QStatusBar QLabel{color:#4b5563;}
QListWidget{background-color:#ffffff;color:#111827;border:1px solid:#d1d5db;}
QListWidget#verticalTabList{border-right:1px solid:#d1d5db;border-top:none;border-bottom:none;}
QListWidget#verticalTabList::item{padding:6px 10px;margin:2px 4px;border-radius:6px;}
QListWidget#verticalTabList::item:selected{background-color:#2563eb;color:#ffffff;}
QListWidget#verticalTabList::item:hover{background-color:#dbeafe;color:#111827;}
QDialog{background-color:#ffffff;color:#111827;}
QDialog QLabel{color:#111827;}
QPushButton{background-color:#2563eb;color:#ffffff;border-radius:8px;padding:6px 12px;border:none;}
QPushButton:hover{background-color:#1d4ed8;}
QComboBox{background-color:#ffffff;color:#111827;border-radius:8px;padding:2px 6px;border:1px solid:#d1d5db;}
QComboBox QAbstractItemView{background-color:#ffffff;color:#111827;selection-background-color:#e5e7eb;}
QToolButton{background-color:transparent;color:#4b5563;padding:4px 6px;border-radius:6px;}
QToolButton::menu-indicator{image:none;}
QToolButton:hover{background-color:#e5e7eb;color:#111827;}
QMenu{background-color:#ffffff;color:#111827;border:1px solid:#d1d5db;}
QMenu::item:selected{background-color:#e5e7eb;}
"""
def register_in_installed_apps():
    if not getattr(sys, "frozen", False):
        return
    exe_path = os.path.abspath(sys.executable)
    install_dir = os.path.dirname(exe_path)
    key_path = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\WebBrowser"
    key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path)
    winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "Web Browser")
    winreg.SetValueEx(key, "DisplayVersion", 0, winreg.REG_SZ, "1.0.0")
    winreg.SetValueEx(key, "Publisher", 0, winreg.REG_SZ, "Sean")
    winreg.SetValueEx(key, "InstallLocation", 0, winreg.REG_SZ, install_dir)
    winreg.SetValueEx(key, "DisplayIcon", 0, winreg.REG_SZ, exe_path)
    winreg.SetValueEx(key, "UninstallString", 0, winreg.REG_SZ, f'"{exe_path}" --uninstall')
    key.Close()
def unregister_from_installed_apps():
    key_path = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\WebBrowser"
    try:
        winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key_path)
    except FileNotFoundError:
        pass
class PasswordManager:
    SERVICE_NAME = "WebBrowser"
    def __init__(self):
        self.available = keyring is not None
        self.index = {}
        self._load_index()
    def _load_index(self):
        if PASSWORD_INDEX_FILE.exists():
            try:
                with PASSWORD_INDEX_FILE.open("r", encoding="utf-8") as f:
                    self.index = json.load(f)
            except Exception:
                self.index = {}
        else:
            self.index = {}
    def _save_index(self):
        try:
            with PASSWORD_INDEX_FILE.open("w", encoding="utf-8") as f:
                json.dump(self.index, f, indent=2)
        except Exception:
            pass
    def origin_for_url(self, url: str) -> str:
        parsed = urlparse(url)
        if not parsed.scheme or not parsed.netloc:
            return ""
        return f"{parsed.scheme}://{parsed.netloc}".lower()
    def save_password(self, url: str, username: str, password: str) -> bool:
        if not self.available:
            return False
        origin = self.origin_for_url(url)
        if not origin or not username:
            return False
        key = f"{origin}|{username}"
        try:
            keyring.set_password(self.SERVICE_NAME, key, password)
        except Exception:
            return False
        usernames = self.index.get(origin, [])
        if username not in usernames:
            usernames.append(username)
            self.index[origin] = usernames
            self._save_index()
        return True
    def list_usernames(self, url: str):
        origin = self.origin_for_url(url)
        if not origin:
            return []
        return list(self.index.get(origin, []))
    def get_password(self, url: str, username: str):
        if not self.available:
            return None
        origin = self.origin_for_url(url)
        if not origin:
            return None
        key = f"{origin}|{username}"
        try:
            return keyring.get_password(self.SERVICE_NAME, key)
        except Exception:
            return None
class PrivacyRequestInterceptor(QWebEngineUrlRequestInterceptor):
    def __init__(self, level="max", mode="default", kids_whitelist=None, parent=None):
        super().__init__(parent)
        self.level = level
        self.mode = mode
        self.kids_whitelist = kids_whitelist or set()
        self.tracker_domains = {
            "doubleclick.net",
            "google-analytics.com",
            "googletagmanager.com",
            "facebook.net",
            "facebook.com",
            "adservice.google.com",
            "scorecardresearch.com",
            "quantserve.com",
            "adsrvr.org",
        }
    def set_level(self, level: str):
        self.level = level
    def interceptRequest(self, info):
        url = info.requestUrl()
        host = url.host().lower()
        scheme = url.scheme().lower()
        if self.mode == "kids":
            if scheme == "file":
                info.block(True)
                return
            if scheme in ("http", "https"):
                if self.kids_whitelist:
                    allowed = any(host.endswith(dom) for dom in self.kids_whitelist)
                    if not allowed:
                        info.block(True)
                        return
        if self.level == "off":
            return
        if self.level in ("moderate", "max"):
            for tracker in self.tracker_domains:
                if tracker in host:
                    info.block(True)
                    return
class BrowserTab(QWebEngineView):
    new_tab_requested = pyqtSignal(object)
    def __init__(self, profile: QWebEngineProfile, parent=None):
        super().__init__(parent)
        page = QWebEnginePage(profile, self)
        self.setPage(page)
        s = self.settings()
        s.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
        s.setAttribute(QWebEngineSettings.WebAttribute.PluginsEnabled, True)
        s.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
        s.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
        s.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True)
    def createWindow(self, _type):
        new_view = BrowserTab(self.page().profile(), self.parent())
        self.new_tab_requested.emit(new_view)
        return new_view
class CommandPalette(QDialog):
    def __init__(self, parent, commands: dict):
        super().__init__(parent)
        self.setModal(True)
        self.setWindowFlags(
            Qt.WindowType.Dialog
            | Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.WindowStaysOnTopHint
        )
        self.resize(480, 320)
        self.commands = commands
        layout = QVBoxLayout(self)
        title_label = QLabel("Command Palette")
        title_label.setStyleSheet("font-weight:bold;font-size:14px;")
        layout.addWidget(title_label)
        self.input = QLineEdit(self)
        self.input.setPlaceholderText("Type a command…")
        layout.addWidget(self.input)
        self.list_widget = QListWidget(self)
        layout.addWidget(self.list_widget)
        buttons_layout = QHBoxLayout()
        self.run_button = QPushButton("Run", self)
        self.close_button = QPushButton("Close", self)
        buttons_layout.addStretch()
        buttons_layout.addWidget(self.run_button)
        buttons_layout.addWidget(self.close_button)
        layout.addLayout(buttons_layout)
        self.input.textChanged.connect(self.update_filter)
        self.list_widget.itemDoubleClicked.connect(self.run_selected)
        self.run_button.clicked.connect(self.run_selected)
        self.close_button.clicked.connect(self.reject)
        self.populate_list()
        self.input.setFocus()
    def populate_list(self):
        self.list_widget.clear()
        for label in sorted(self.commands.keys()):
            QListWidgetItem(label, self.list_widget)
    def update_filter(self, text: str):
        text = text.lower()
        self.list_widget.clear()
        for label in sorted(self.commands.keys()):
            if text in label.lower():
                QListWidgetItem(label, self.list_widget)
    def run_selected(self):
        item = self.list_widget.currentItem()
        if not item:
            return
        label = item.text()
        cmd = self.commands.get(label)
        if cmd:
            self.accept()
            cmd()
class TabSearchDialog(QDialog):
    def __init__(self, parent, tabs_info):
        super().__init__(parent)
        self.setModal(True)
        self.setWindowFlags(
            Qt.WindowType.Dialog
            | Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.WindowStaysOnTopHint
        )
        self.resize(480, 360)
        self.tabs_info = tabs_info
        self.selected_index = None
        layout = QVBoxLayout(self)
        title_label = QLabel("Search Tabs")
        title_label.setStyleSheet("font-weight:bold;font-size:14px;")
        layout.addWidget(title_label)
        self.input = QLineEdit(self)
        self.input.setPlaceholderText("Search tab by title or URL…")
        layout.addWidget(self.input)
        self.list_widget = QListWidget(self)
        layout.addWidget(self.list_widget)
        buttons_layout = QHBoxLayout()
        self.open_button = QPushButton("Switch", self)
        self.close_button = QPushButton("Close", self)
        buttons_layout.addStretch()
        buttons_layout.addWidget(self.open_button)
        buttons_layout.addWidget(self.close_button)
        layout.addLayout(buttons_layout)
        self.input.textChanged.connect(self.update_filter)
        self.list_widget.itemDoubleClicked.connect(self.choose_selected)
        self.open_button.clicked.connect(self.choose_selected)
        self.close_button.clicked.connect(self.reject)
        self.populate_list()
        self.input.setFocus()
    def populate_list(self, pattern: str = ""):
        self.list_widget.clear()
        pattern = pattern.lower()
        for idx, title, url in self.tabs_info:
            display = f"[{idx}] {title}  —  {url}"
            if pattern and pattern not in display.lower():
                continue
            item = QListWidgetItem(display, self.list_widget)
            item.setData(Qt.ItemDataRole.UserRole, idx)
    def update_filter(self, text: str):
        self.populate_list(text)
    def choose_selected(self):
        item = self.list_widget.currentItem()
        if not item:
            return
        self.selected_index = item.data(Qt.ItemDataRole.UserRole)
        self.accept()
class DownloadsDialog(QDialog):
    def __init__(self, parent, downloads_ref):
        super().__init__(parent)
        self.setModal(True)
        self.setWindowFlags(
            Qt.WindowType.Dialog
            | Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.WindowStaysOnTopHint
        )
        self.resize(520, 360)
        self.downloads_ref = downloads_ref
        layout = QVBoxLayout(self)
        title_label = QLabel("Downloads", self)
        title_label.setStyleSheet("font-weight:bold;font-size:14px;")
        layout.addWidget(title_label)
        self.list_widget = QListWidget(self)
        layout.addWidget(self.list_widget, 1)
        buttons_layout = QHBoxLayout()
        buttons_layout.addStretch()
        self.open_btn = QPushButton("Open", self)
        self.show_btn = QPushButton("Show in folder", self)
        self.close_btn = QPushButton("Close", self)
        buttons_layout.addWidget(self.show_btn)
        buttons_layout.addWidget(self.open_btn)
        buttons_layout.addWidget(self.close_btn)
        layout.addLayout(buttons_layout)
        self.list_widget.itemDoubleClicked.connect(self.open_selected)
        self.open_btn.clicked.connect(self.open_selected)
        self.show_btn.clicked.connect(self.show_in_folder)
        self.close_btn.clicked.connect(self.accept)
        self.refresh()
    def refresh(self):
        self.list_widget.clear()
        for idx, rec in enumerate(self.downloads_ref):
            file_name = rec.get("file_name") or "(unknown)"
            state = rec.get("state") or ""
            received = rec.get("received_bytes") or 0
            total = rec.get("total_bytes") or 0
            if total > 0:
                pct = int(received * 100 / total)
                state_str = f"{state} ({pct}%)"
            else:
                state_str = state
            text = f"{file_name}  —  {state_str}"
            item = QListWidgetItem(text, self.list_widget)
            item.setData(Qt.ItemDataRole.UserRole, idx)
    def _selected_record(self):
        item = self.list_widget.currentItem()
        if not item:
            return None
        idx = item.data(Qt.ItemDataRole.UserRole)
        if idx is None or not (0 <= idx < len(self.downloads_ref)):
            return None
        return self.downloads_ref[idx]
    def open_selected(self):
        rec = self._selected_record()
        if not rec:
            return
        path = rec.get("path") or ""
        if not path or not os.path.exists(path):
            return
        QDesktopServices.openUrl(QUrl.fromLocalFile(path))
    def show_in_folder(self):
        rec = self._selected_record()
        if not rec:
            return
        path = rec.get("path") or ""
        if not path:
            return
        folder = path
        if os.path.isfile(path):
            folder = os.path.dirname(path) or path
        if not os.path.exists(folder):
            return
        QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
class HistoryDialog(QDialog):
    def __init__(self, parent, history_items):
        super().__init__(parent)
        self.setModal(True)
        self.setWindowFlags(
            Qt.WindowType.Dialog
            | Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.WindowStaysOnTopHint
        )
        self.resize(640, 420)
        self.all_items = list(history_items)
        layout = QVBoxLayout(self)
        title_label = QLabel("History", self)
        title_label.setStyleSheet("font-weight:bold;font-size:14px;")
        layout.addWidget(title_label)
        controls_layout = QHBoxLayout()
        self.search_edit = QLineEdit(self)
        self.search_edit.setPlaceholderText("Search by title or URL…")
        controls_layout.addWidget(self.search_edit, 1)
        self.date_filter = QComboBox(self)
        self.date_filter.addItems(["All time", "Today", "Last 7 days", "Last 30 days"])
        controls_layout.addWidget(self.date_filter)
        layout.addLayout(controls_layout)
        self.list_widget = QListWidget(self)
        layout.addWidget(self.list_widget, 1)
        buttons_layout = QHBoxLayout()
        buttons_layout.addStretch()
        clear_btn = QPushButton("Delete browsing data…", self)
        open_btn = QPushButton("Open", self)
        close_btn = QPushButton("Close", self)
        buttons_layout.addWidget(clear_btn)
        buttons_layout.addWidget(open_btn)
        buttons_layout.addWidget(close_btn)
        layout.addLayout(buttons_layout)
        self.search_edit.textChanged.connect(self.refresh_list)
        self.date_filter.currentIndexChanged.connect(self.refresh_list)
        self.list_widget.itemDoubleClicked.connect(self.open_selected)
        open_btn.clicked.connect(self.open_selected)
        close_btn.clicked.connect(self.accept)
        clear_btn.clicked.connect(self.clear_history_clicked)
        self.refresh_list()
    def refresh_list(self):
        self.list_widget.clear()
        q = self.search_edit.text().lower().strip()
        idx = self.date_filter.currentIndex()
        now = datetime.utcnow()
        for entry in reversed(self.all_items):
            url = entry.get("url", "")
            title = entry.get("title", "")
            ts_str = entry.get("timestamp", "")
            try:
                dt = datetime.fromisoformat(ts_str)
            except Exception:
                dt = None
            include = True
            if idx == 1 and dt is not None:
                include = dt.date() == now.date()
            elif idx == 2 and dt is not None:
                include = dt >= now - timedelta(days=7)
            elif idx == 3 and dt is not None:
                include = dt >= now - timedelta(days=30)
            if not include:
                continue
            if q and (q not in title.lower() and q not in url.lower()):
                continue
            when = dt.strftime("%Y-%m-%d %H:%M") if dt is not None else ""
            text = f"{when}  —  {title}  —  {url}"
            item = QListWidgetItem(text, self.list_widget)
            item.setData(Qt.ItemDataRole.UserRole, url)
    def open_selected(self):
        item = self.list_widget.currentItem()
        if not item:
            return
        url = item.data(Qt.ItemDataRole.UserRole)
        if not url:
            return
        parent = self.parent()
        if parent is None:
            return
        parent.new_tab(QUrl(url))
    def clear_history_clicked(self):
        parent = self.parent()
        if parent is None or not hasattr(parent, "delete_browsing_data"):
            return
        parent.delete_browsing_data()
        self.all_items = list(parent.history)
        self.refresh_list()
class VerticalTabList(QListWidget):
    tab_close_requested = pyqtSignal(int)
    tab_duplicate_requested = pyqtSignal(int)
    tab_close_others_requested = pyqtSignal(int)
    tab_reorder_requested = pyqtSignal(int, int)
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self._show_context_menu)
        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        self.setDefaultDropAction(Qt.DropAction.MoveAction)
        self._drag_row = None
    def startDrag(self, supportedActions):
        self._drag_row = self.currentRow()
        super().startDrag(supportedActions)
    def dropEvent(self, event):
        super().dropEvent(event)
        if self._drag_row is None:
            return
        new_row = self.currentRow()
        from_row = self._drag_row
        self._drag_row = None
        if new_row != -1 and new_row != from_row:
            self.tab_reorder_requested.emit(from_row, new_row)
    def _show_context_menu(self, pos):
        row = self.indexAt(pos).row()
        if row < 0:
            return
        menu = QMenu(self)
        act_close = menu.addAction("Close Tab")
        act_dup = menu.addAction("Duplicate Tab")
        act_close_others = menu.addAction("Close Other Tabs")
        if self.count() <= 1:
            act_close_others.setEnabled(False)
        chosen = menu.exec(self.mapToGlobal(pos))
        if chosen == act_close:
            self.tab_close_requested.emit(row)
        elif chosen == act_dup:
            self.tab_duplicate_requested.emit(row)
        elif chosen == act_close_others:
            self.tab_close_others_requested.emit(row)
class ClickableLabel(QLabel):
    clicked = pyqtSignal()
    def mousePressEvent(self, event):
        self.clicked.emit()
        super().mousePressEvent(event)
class DrawingCanvas(QWidget):
    def __init__(self, parent=None, width=400, height=200):
        super().__init__(parent)
        self.pen_color = QColor(0, 0, 0)
        self.pen_width = 2
        self._image = QImage(width, height, QImage.Format.Format_ARGB32)
        self._image.fill(Qt.GlobalColor.white)
        self._last_pos = None
    def sizeHint(self):
        return QSize(self._image.width() + 20, self._image.height() + 20)
    def _page_rect(self) -> QRect:
        size = self._image.size()
        x = max((self.width() - size.width()) // 2, 10)
        y = max((self.height() - size.height()) // 2, 10)
        return QRect(x, y, size.width(), size.height())
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(self.rect(), QColor(230, 230, 230))
        page_rect = self._page_rect()
        painter.fillRect(page_rect, Qt.GlobalColor.white)
        painter.drawImage(page_rect.topLeft(), self._image)
    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            page_rect = self._page_rect()
            pos = event.position().toPoint()
            if page_rect.contains(pos):
                self._last_pos = pos - page_rect.topLeft()
    def mouseMoveEvent(self, event):
        if (event.buttons() & Qt.MouseButton.LeftButton) and self._last_pos is not None:
            page_rect = self._page_rect()
            pos = event.position().toPoint()
            if not page_rect.contains(pos):
                return
            painter = QPainter(self._image)
            painter.setPen(QPen(
                self.pen_color,
                self.pen_width,
                Qt.PenStyle.SolidLine,
                Qt.PenCapStyle.RoundCap,
                Qt.PenJoinStyle.RoundJoin,
            ))
            painter.drawLine(self._last_pos, pos - page_rect.topLeft())
            self._last_pos = pos - page_rect.topLeft()
            self.update()
    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._last_pos = None
    def clear(self):
        self._image.fill(Qt.GlobalColor.white)
        self.update()
    def get_image(self) -> QImage:
        return self._image.copy()
    def set_pen_color(self, color: QColor):
        self.pen_color = color
    def set_pen_width(self, width: int):
        self.pen_width = max(1, width)
def normalize_pdf_text(raw: str) -> str:
    if not raw:
        return ""
    raw = raw.replace("\r", "\n")
    raw = re.sub(r"[ \t]+", " ", raw)
    raw = re.sub(r"\n{3,}", "\n\n", raw)
    raw = raw.strip()
    if not raw:
        return ""
    placeholder = "\uFFFF"
    raw = raw.replace("\n\n", placeholder)
    raw = raw.replace("\n", " ")
    raw = raw.replace(placeholder, "\n\n")
    raw = re.sub(r" {2,}", " ", raw)
    return raw.strip()
def docx_to_html(path: str) -> str:
    if docx_lib is None:
        return ""
    doc = docx_lib.Document(path)
    parts = []
    for para in doc.paragraphs:
        runs_html = []
        for run in para.runs:
            text = html.escape(run.text, quote=False)
            if not text:
                continue
            if run.bold:
                text = f"{text}"
            if run.italic:
                text = f"{text}"
            if run.underline:
                text = f"{text}"
            runs_html.append(text)
        paragraph_html = "".join(runs_html)
        if not paragraph_html:
            continue
        style_name = para.style.name if para.style is not None else ""
        if style_name.startswith("Heading"):
            digits = "".join(ch for ch in style_name if ch.isdigit())
            level = int(digits) if digits else 1
            level = max(1, min(level, 6))
            parts.append(f"{paragraph_html}")
        else:
            parts.append(f"

{paragraph_html}

") return "\n".join(parts) class PagedTextEdit(QTextEdit): def __init__(self, parent=None, page_size: QSizeF = QSizeF(850, 1100), margin: int = 72): super().__init__(parent) self._page_size = page_size self._page_height = page_size.height() self._page_margin = margin doc = self.document() doc.setPageSize(page_size) doc.setDocumentMargin(margin) self.setAcceptRichText(True) self.setUndoRedoEnabled(True) self.setFont(QFont("Times New Roman", 11)) self.setLineWrapMode(QTextEdit.LineWrapMode.FixedPixelWidth) self.setLineWrapColumnOrWidth(int(page_size.width() - 2 * margin)) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) def page_height(self) -> float: return self._page_height def paintEvent(self, event): super().paintEvent(event) doc = self.document() total_height = float(doc.size().height()) if self._page_height <= 0 or total_height <= self._page_height: return pages = int(total_height // self._page_height) if total_height % self._page_height > 1: pages += 1 painter = QPainter(self.viewport()) pen = QPen(QColor(200, 200, 200)) pen.setStyle(Qt.PenStyle.DashLine) painter.setPen(pen) offset = self.verticalScrollBar().value() left = 16 right = self.viewport().width() - 16 for i in range(1, pages): doc_y = i * self._page_height view_y = int(doc_y - offset) if -20 <= view_y <= self.viewport().height() + 20: painter.drawLine(left, view_y, right, view_y) class DocumentEditorWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.setObjectName("DocumentEditor") self.setWindowTitle("Document Editor") self.resize(1100, 800) self.current_path = None page_size = QSizeF(850, 1100) self.text_edit = PagedTextEdit(self, page_size=page_size, margin=72) central = QWidget(self) central.setObjectName("DocCentral") layout = QVBoxLayout(central) layout.setContentsMargins(40, 24, 40, 24) layout.addWidget(self.text_edit) self.setCentralWidget(central) self.signature_canvas = DrawingCanvas(self, width=400, height=180) self.signature_dock = QDockWidget("Signature", self) self.signature_dock.setObjectName("SignatureDock") sig_container = QWidget(self.signature_dock) sig_layout = QVBoxLayout(sig_container) sig_layout.setContentsMargins(8, 8, 8, 8) sig_layout.addWidget(self.signature_canvas) sig_btn_row = QHBoxLayout() sig_btn_row.addStretch() self.clear_signature_btn = QPushButton("Clear Signature", sig_container) self.add_signature_btn = QPushButton("Add to Document", sig_container) self.clear_signature_btn.clicked.connect(self.signature_canvas.clear) self.add_signature_btn.clicked.connect(self.add_signature_to_document) sig_btn_row.addWidget(self.clear_signature_btn) sig_btn_row.addWidget(self.add_signature_btn) sig_layout.addLayout(sig_btn_row) self.signature_dock.setWidget(sig_container) self.signature_dock.setAllowedAreas( Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea ) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.signature_dock) self.signature_dock.hide() self.signature_dock.visibilityChanged.connect(self._on_signature_visibility_changed) self._init_toolbar() self._init_statusbar() self._apply_editor_theme() self.text_edit.cursorPositionChanged.connect(self._sync_controls_from_cursor) self.text_edit.textChanged.connect(self._update_statusbar) self._update_statusbar() def _init_toolbar(self): tb = QToolBar("Document", self) tb.setMovable(False) self.addToolBar(tb) open_act = QAction("Open", self) open_act.triggered.connect(self.open_file) tb.addAction(open_act) save_act = QAction("Save", self) save_act.triggered.connect(self.save_file) tb.addAction(save_act) save_as_act = QAction("Save As", self) save_as_act.triggered.connect(self.save_file_as) tb.addAction(save_as_act) tb.addSeparator() undo_act = QAction("Undo", self) undo_act.triggered.connect(self.text_edit.undo) tb.addAction(undo_act) redo_act = QAction("Redo", self) redo_act.triggered.connect(self.text_edit.redo) tb.addAction(redo_act) tb.addSeparator() self.font_combo = QFontComboBox(self) self.font_combo.currentFontChanged.connect(self._sync_format_from_controls) tb.addWidget(self.font_combo) self.size_spin = QSpinBox(self) self.size_spin.setRange(6, 72) self.size_spin.setValue(11) self.size_spin.valueChanged.connect(self._sync_format_from_controls) tb.addWidget(self.size_spin) self.bold_act = QAction("B", self) self.bold_act.setCheckable(True) self.bold_act.triggered.connect(self._toggle_bold) tb.addAction(self.bold_act) self.italic_act = QAction("I", self) self.italic_act.setCheckable(True) self.italic_act.triggered.connect(self._toggle_italic) tb.addAction(self.italic_act) self.underline_act = QAction("U", self) self.underline_act.setCheckable(True) self.underline_act.triggered.connect(self._toggle_underline) tb.addAction(self.underline_act) tb.addSeparator() color_act = QAction("Text color", self) color_act.triggered.connect(self._choose_color) tb.addAction(color_act) self.highlight_act = QAction("Highlight", self) self.highlight_act.setToolTip("Highlight selected text") self.highlight_act.triggered.connect(self._highlight_selection) tb.addAction(self.highlight_act) tb.addSeparator() self.align_group = QActionGroup(self) self.align_left_act = QAction("⯇", self) self.align_left_act.setCheckable(True) self.align_left_act.triggered.connect( lambda: self._set_alignment(Qt.AlignmentFlag.AlignLeft) ) self.align_group.addAction(self.align_left_act) tb.addAction(self.align_left_act) self.align_center_act = QAction("⭮", self) self.align_center_act.setCheckable(True) self.align_center_act.triggered.connect( lambda: self._set_alignment(Qt.AlignmentFlag.AlignHCenter) ) self.align_group.addAction(self.align_center_act) tb.addAction(self.align_center_act) self.align_right_act = QAction("⯈", self) self.align_right_act.setCheckable(True) self.align_right_act.triggered.connect( lambda: self._set_alignment(Qt.AlignmentFlag.AlignRight) ) self.align_group.addAction(self.align_right_act) tb.addAction(self.align_right_act) self.align_justify_act = QAction("⯀", self) self.align_justify_act.setCheckable(True) self.align_justify_act.triggered.connect( lambda: self._set_alignment(Qt.AlignmentFlag.AlignJustify) ) self.align_group.addAction(self.align_justify_act) tb.addAction(self.align_justify_act) self.align_left_act.setChecked(True) tb.addSeparator() self.bullet_act = QAction("• List", self) self.bullet_act.triggered.connect(self._toggle_bullet_list) tb.addAction(self.bullet_act) self.number_act = QAction("1. List", self) self.number_act.triggered.connect(self._toggle_numbered_list) tb.addAction(self.number_act) tb.addSeparator() self.draw_dialog_act = QAction("Draw", self) self.draw_dialog_act.setToolTip("Draw and insert into document") self.draw_dialog_act.triggered.connect(self.open_draw_dialog) tb.addAction(self.draw_dialog_act) self.signature_panel_act = QAction("Signature panel", self) self.signature_panel_act.setCheckable(True) self.signature_panel_act.setToolTip("Show signature panel") self.signature_panel_act.triggered.connect(self._toggle_signature_panel) tb.addAction(self.signature_panel_act) def _merge_format_on_selection(self, fmt: QTextCharFormat): cursor = self.text_edit.textCursor() if not cursor.hasSelection(): cursor.select(QTextCursor.SelectionType.WordUnderCursor) cursor.mergeCharFormat(fmt) self.text_edit.mergeCurrentCharFormat(fmt) def _sync_format_from_controls(self): fmt = QTextCharFormat() fmt.setFont(self.font_combo.currentFont()) fmt.setFontPointSize(self.size_spin.value()) self._merge_format_on_selection(fmt) def _toggle_bold(self, checked): fmt = QTextCharFormat() fmt.setFontWeight(QFont.Weight.Bold if checked else QFont.Weight.Normal) self._merge_format_on_selection(fmt) def _toggle_italic(self, checked): fmt = QTextCharFormat() fmt.setFontItalic(checked) self._merge_format_on_selection(fmt) def _toggle_underline(self, checked): fmt = QTextCharFormat() fmt.setFontUnderline(checked) self._merge_format_on_selection(fmt) def _choose_color(self): color = QColorDialog.getColor(self.text_edit.textColor(), self, "Text color") if color.isValid(): fmt = QTextCharFormat() fmt.setForeground(color) self._merge_format_on_selection(fmt) def _set_alignment(self, alignment): self.text_edit.setAlignment(alignment) def _toggle_bullet_list(self): cursor = self.text_edit.textCursor() current_list = cursor.currentList() if current_list and current_list.format().style() == QTextListFormat.Style.ListDisc: current_list.remove(cursor.block()) else: fmt = QTextListFormat() fmt.setStyle(QTextListFormat.Style.ListDisc) cursor.createList(fmt) self.text_edit.setTextCursor(cursor) def _toggle_numbered_list(self): cursor = self.text_edit.textCursor() current_list = cursor.currentList() if current_list and current_list.format().style() == QTextListFormat.Style.ListDecimal: current_list.remove(cursor.block()) else: fmt = QTextListFormat() fmt.setStyle(QTextListFormat.Style.ListDecimal) cursor.createList(fmt) self.text_edit.setTextCursor(cursor) def _sync_controls_from_cursor(self): cursor = self.text_edit.textCursor() fmt = cursor.charFormat() block_fmt = cursor.blockFormat() self.bold_act.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.italic_act.setChecked(fmt.fontItalic()) self.underline_act.setChecked(fmt.fontUnderline()) self.font_combo.blockSignals(True) self.font_combo.setCurrentFont(fmt.font() if fmt.font() else self.text_edit.font()) self.font_combo.blockSignals(False) if fmt.fontPointSize() > 0: self.size_spin.blockSignals(True) self.size_spin.setValue(int(fmt.fontPointSize())) self.size_spin.blockSignals(False) align = block_fmt.alignment() self.align_left_act.setChecked(bool(align & Qt.AlignmentFlag.AlignLeft)) self.align_center_act.setChecked(bool(align & Qt.AlignmentFlag.AlignHCenter)) self.align_right_act.setChecked(bool(align & Qt.AlignmentFlag.AlignRight)) self.align_justify_act.setChecked(bool(align & Qt.AlignmentFlag.AlignJustify)) def _init_statusbar(self): sb = QStatusBar(self) self.setStatusBar(sb) self.status_label = QLabel("Words: 0 | Characters: 0", self) sb.addPermanentWidget(self.status_label) def _update_statusbar(self): text = self.text_edit.toPlainText() words = len(text.split()) if text.strip() else 0 chars = len(text) self.status_label.setText(f"Words: {words} | Characters: {chars}") def _apply_editor_theme(self): self.setStyleSheet(""" QMainWindow#DocumentEditor { background-color: #d4d4d4; } QWidget#DocCentral { background-color: #d4d4d4; } QTextEdit { background-color: #ffffff; border: 1px solid #cccccc; border-radius: 4px; } QToolBar { background: #f5f5f5; border-bottom: 1px solid #cccccc; } QStatusBar { background: #f5f5f5; border-top: 1px solid #cccccc; } QDockWidget#SignatureDock { background: #f5f5f5; border-left: 1px solid #cccccc; } """) def _highlight_selection(self): cursor = self.text_edit.textCursor() if not cursor.hasSelection(): return fmt = QTextCharFormat() fmt.setBackground(QColor("#fff59d")) cursor.mergeCharFormat(fmt) def _toggle_signature_panel(self, checked: bool): if checked: self.signature_dock.show() else: self.signature_dock.hide() def _on_signature_visibility_changed(self, visible: bool): if hasattr(self, "signature_panel_act"): self.signature_panel_act.setChecked(visible) def _insert_canvas_image_into_document(self, canvas: DrawingCanvas): img = canvas.get_image() if img.isNull(): return cursor = self.text_edit.textCursor() doc = self.text_edit.document() name = f"img-{int(datetime.now().timestamp())}" url = QUrl(name) doc.addResource(QTextDocument.ResourceType.ImageResource, url, img) fmt = QTextImageFormat() fmt.setName(url.toString()) page_width = doc.pageSize().width() or float(self.text_edit.viewport().width()) margin = doc.documentMargin() max_width = max(50.0, page_width - 2 * margin) scale = 1.0 if img.width() > max_width: scale = max_width / img.width() fmt.setWidth(img.width() * scale) fmt.setHeight(img.height() * scale) cursor.insertImage(fmt) def add_signature_to_document(self): self._insert_canvas_image_into_document(self.signature_canvas) def open_draw_dialog(self): dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) dlg.setMinimumWidth(500) layout = QVBoxLayout(dlg) title = QLabel("Draw", dlg) title.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title) canvas = DrawingCanvas(dlg, width=500, height=300) layout.addWidget(canvas) btn_row = QHBoxLayout() btn_row.addStretch() clear_btn = QPushButton("Clear", dlg) insert_btn = QPushButton("Insert into document", dlg) close_btn = QPushButton("Close", dlg) btn_row.addWidget(clear_btn) btn_row.addWidget(insert_btn) btn_row.addWidget(close_btn) layout.addLayout(btn_row) def do_clear(): canvas.clear() def do_insert(): self._insert_canvas_image_into_document(canvas) clear_btn.clicked.connect(do_clear) insert_btn.clicked.connect(do_insert) close_btn.clicked.connect(dlg.accept) dlg.resize(520, 380) dlg.exec() def import_pdf_as_text(self, path: str): if PdfReader is None: QMessageBox.warning( self, "PDF support not available", "PyPDF2 is not installed.\n\nInstall it with:\n pip install PyPDF2", ) return try: reader = PdfReader(path) except Exception as e: QMessageBox.warning(self, "PDF open error", f"Could not read PDF file:\n{e}") return parts = [] for i, page in enumerate(reader.pages): try: page_text = page.extract_text() or "" except Exception: page_text = "" norm = normalize_pdf_text(page_text) if not norm: continue if parts: parts.append(f"\n\n----- Page {i+1} -----\n\n") parts.append(norm) self.text_edit.setPlainText("".join(parts)) def import_pdf_with_images_and_text(self, path: str): if fitz is None: self.import_pdf_as_text(path) return try: pdf_doc = fitz.open(path) except Exception as e: QMessageBox.warning(self, "PDF open error", f"Could not read PDF file:\n{e}") return self.text_edit.clear() cursor = self.text_edit.textCursor() cursor.movePosition(QTextCursor.MoveOperation.Start) cursor.beginEditBlock() qt_doc = self.text_edit.document() page_width = qt_doc.pageSize().width() or float(self.text_edit.viewport().width()) margin = qt_doc.documentMargin() max_img_width = max(50.0, page_width - 2 * margin) for page_index in range(len(pdf_doc)): page = pdf_doc[page_index] try: zoom = 2.0 mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat, alpha=False) img = QImage( pix.samples, pix.width, pix.height, pix.stride, QImage.Format.Format_RGB888, ).copy() img_name = f"pdf-page-{page_index+1}-{int(datetime.now().timestamp())}" img_url = QUrl(img_name) qt_doc.addResource(QTextDocument.ResourceType.ImageResource, img_url, img) img_fmt = QTextImageFormat() img_fmt.setName(img_url.toString()) scale = 1.0 if img.width() > max_img_width: scale = max_img_width / img.width() img_fmt.setWidth(img.width() * scale) img_fmt.setHeight(img.height() * scale) cursor.insertImage(img_fmt) cursor.insertBlock() except Exception: pass try: raw_text = page.get_text("text") except Exception: raw_text = "" norm = normalize_pdf_text(raw_text) if norm: cursor.insertText(norm) cursor.insertBlock() if page_index < len(pdf_doc) - 1: cursor.insertBlock() sep_fmt = cursor.blockFormat() sep_fmt.setAlignment(Qt.AlignmentFlag.AlignHCenter) cursor.setBlockFormat(sep_fmt) cursor.insertText(f"——— Page {page_index+1} end / Page {page_index+2} start ———") cursor.insertBlock() cursor.insertBlock() cursor.endEditBlock() pdf_doc.close() def open_file(self): path, _ = QFileDialog.getOpenFileName( self, "Open document", "", "Documents (*.txt *.html *.htm *.docx *.pdf);;All files (*.*)" ) if not path: return ext = os.path.splitext(path)[1].lower() try: if ext == ".docx": try: html_text = docx_to_html(path) except Exception as e: QMessageBox.warning( self, "DOCX open error", f"Could not read DOCX file:\n{e}" ) return self.text_edit.setHtml(html_text) elif ext == ".pdf": try: self.import_pdf_with_images_and_text(path) except Exception as e: QMessageBox.warning( self, "PDF open error", f"Could not import PDF with images/text:\n{e}\n\n" "Falling back to text-only import.", ) self.import_pdf_as_text(path) else: with open(path, "r", encoding="utf-8", errors="ignore") as f: text = f.read() if ext in (".html", ".htm"): self.text_edit.setHtml(text) else: self.text_edit.setPlainText(text) except Exception as e: QMessageBox.warning(self, "Open error", f"Could not open file:\n{e}") return self.current_path = path self.setWindowTitle(f"Document Editor - {os.path.basename(path)}") def save_file(self): if not self.current_path: self.save_file_as() else: self._save_to_path(self.current_path) def save_file_as(self): path, _ = QFileDialog.getSaveFileName( self, "Save document as", self.current_path or "", "Text files (*.txt);;Word documents (*.docx);;HTML files (*.html);;All files (*.*)" ) if not path: return ext = os.path.splitext(path)[1].lower() if not ext: path += ".txt" self._save_to_path(path) def _save_to_path(self, path: str): ext = os.path.splitext(path)[1].lower() try: if ext == ".docx": if docx_lib is None: QMessageBox.warning( self, "DOCX support not available", "python-docx is not installed.\n\n" "Install it with:\n pip install python-docx", ) return doc = docx_lib.Document() for line in self.text_edit.toPlainText().splitlines(): doc.add_paragraph(line) doc.save(path) elif ext in (".html", ".htm"): with open(path, "w", encoding="utf-8") as f: f.write(self.text_edit.toHtml()) else: with open(path, "w", encoding="utf-8") as f: f.write(self.text_edit.toPlainText()) except Exception as e: QMessageBox.warning(self, "Save error", f"Could not save file:\n{e}") else: self.current_path = path self.setWindowTitle(f"Document Editor - {os.path.basename(path)}") class SheetEditorWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.setObjectName("SheetEditor") self.setWindowTitle("Sheet Editor") self.resize(1000, 700) self.current_path = None self.table = QTableWidget(1, 1, self) self.table.setAlternatingRowColors(True) self.table.horizontalHeader().setStretchLastSection(True) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.table.verticalHeader().setDefaultSectionSize(22) self.setCentralWidget(self.table) self._init_toolbar() self._init_statusbar() self.new_sheet() self.table.itemChanged.connect(self._update_statusbar) self._update_statusbar() def _init_toolbar(self): tb = QToolBar("Sheet", self) tb.setMovable(False) self.addToolBar(tb) new_act = QAction("New", self) new_act.triggered.connect(self.new_sheet) tb.addAction(new_act) open_act = QAction("Open", self) open_act.triggered.connect(self.open_file) tb.addAction(open_act) save_act = QAction("Save", self) save_act.triggered.connect(self.save_file) tb.addAction(save_act) save_as_act = QAction("Save As", self) save_as_act.triggered.connect(self.save_file_as) tb.addAction(save_as_act) tb.addSeparator() add_row_act = QAction("+Row", self) add_row_act.triggered.connect(self.insert_row) tb.addAction(add_row_act) add_col_act = QAction("+Col", self) add_col_act.triggered.connect(self.insert_column) tb.addAction(add_col_act) del_row_act = QAction("−Row", self) del_row_act.triggered.connect(self.delete_row) tb.addAction(del_row_act) del_col_act = QAction("−Col", self) del_col_act.triggered.connect(self.delete_column) tb.addAction(del_col_act) def _init_statusbar(self): sb = QStatusBar(self) self.setStatusBar(sb) self.status_label = QLabel("", self) sb.addPermanentWidget(self.status_label) def _column_labels(self, count: int): headers = [] for i in range(count): n = i name = "" while True: name = chr(ord("A") + (n % 26)) + name n = n // 26 - 1 if n < 0: break headers.append(name) return headers def new_sheet(self): rows = 50 cols = 12 self.table.clear() self.table.setRowCount(rows) self.table.setColumnCount(cols) self.table.setHorizontalHeaderLabels(self._column_labels(cols)) self.current_path = None self.setWindowTitle("Sheet Editor") self._update_statusbar() def open_file(self): path, _ = QFileDialog.getOpenFileName( self, "Open sheet", "", "Sheets (*.csv *.xlsx);;CSV files (*.csv);;Excel files (*.xlsx);;All files (*.*)" ) if not path: return ext = os.path.splitext(path)[1].lower() try: if ext == ".xlsx": self._load_xlsx(path) else: self._load_csv(path) except Exception as e: QMessageBox.warning(self, "Open error", f"Could not open sheet:\n{e}") return self.current_path = path self.setWindowTitle(f"Sheet Editor - {os.path.basename(path)}") self._update_statusbar() def save_file(self): if not self.current_path: self.save_file_as() else: self._save_to_path(self.current_path) def save_file_as(self): path, _ = QFileDialog.getSaveFileName( self, "Save sheet as", self.current_path or "", "CSV files (*.csv);;Excel files (*.xlsx);;All files (*.*)" ) if not path: return ext = os.path.splitext(path)[1].lower() if not ext: path += ".csv" self._save_to_path(path) def _save_to_path(self, path: str): ext = os.path.splitext(path)[1].lower() try: if ext == ".xlsx": self._save_xlsx(path) else: self._save_csv(path) except Exception as e: QMessageBox.warning(self, "Save error", f"Could not save sheet:\n{e}") return self.current_path = path self.setWindowTitle(f"Sheet Editor - {os.path.basename(path)}") def _iter_table_rows(self): rows = self.table.rowCount() cols = self.table.columnCount() for r in range(rows): row_vals = [] non_empty = False for c in range(cols): item = self.table.item(r, c) text = item.text() if item else "" if text: non_empty = True row_vals.append(text) if non_empty: yield row_vals def _load_csv(self, path: str): with open(path, "r", encoding="utf-8", errors="ignore", newline="") as f: reader = csv.reader(f) rows = list(reader) if not rows: self.new_sheet() return max_cols = max(len(row) for row in rows) self.table.clear() self.table.setRowCount(len(rows)) self.table.setColumnCount(max_cols) self.table.setHorizontalHeaderLabels(self._column_labels(max_cols)) for r, row in enumerate(rows): for c, val in enumerate(row): if val != "": self.table.setItem(r, c, QTableWidgetItem(val)) def _save_csv(self, path: str): rows = list(self._iter_table_rows()) with open(path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerows(rows) def _load_xlsx(self, path: str): if openpyxl is None: QMessageBox.warning( self, "Excel support not available", "openpyxl is not installed.\n\nInstall it with:\n pip install openpyxl", ) return wb = openpyxl.load_workbook(path, data_only=True) ws = wb.active rows = [] max_cols = 0 for row in ws.iter_rows(): vals = [] for cell in row: v = cell.value vals.append("" if v is None else str(v)) max_cols = max(max_cols, len(vals)) rows.append(vals) wb.close() if not rows: self.new_sheet() return self.table.clear() self.table.setRowCount(len(rows)) self.table.setColumnCount(max_cols) self.table.setHorizontalHeaderLabels(self._column_labels(max_cols)) for r, row in enumerate(rows): for c, val in enumerate(row): if val != "": self.table.setItem(r, c, QTableWidgetItem(val)) def _save_xlsx(self, path: str): if openpyxl is None: QMessageBox.warning( self, "Excel support not available", "openpyxl is not installed.\n\nInstall it with:\n pip install openpyxl", ) return wb = openpyxl.Workbook() ws = wb.active for r_idx, row in enumerate(self._iter_table_rows(), start=1): for c_idx, val in enumerate(row, start=1): ws.cell(row=r_idx, column=c_idx, value=val) wb.save(path) wb.close() def insert_row(self): current = self.table.currentRow() if current < 0: current = self.table.rowCount() self.table.insertRow(current) self._update_statusbar() def insert_column(self): current = self.table.currentColumn() if current < 0: current = self.table.columnCount() self.table.insertColumn(current) self.table.setHorizontalHeaderLabels( self._column_labels(self.table.columnCount()) ) self._update_statusbar() def delete_row(self): current = self.table.currentRow() if current < 0: return self.table.removeRow(current) self._update_statusbar() def delete_column(self): current = self.table.currentColumn() if current < 0: return self.table.removeColumn(current) self.table.setHorizontalHeaderLabels( self._column_labels(self.table.columnCount()) ) self._update_statusbar() def _update_statusbar(self): rows = self.table.rowCount() cols = self.table.columnCount() filled = 0 for r in range(rows): for c in range(cols): item = self.table.item(r, c) if item and item.text(): filled += 1 if hasattr(self, "status_label"): self.status_label.setText( f"Rows: {rows} | Columns: {cols} | Filled cells: {filled}" ) class BrowserWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window ) self.setWindowTitle("Web Browser") self.resize(1280, 800) self.current_profile_name = "Default" self.privacy_level = "max" self.theme = "dark" self.workspaces = {} self.tab_layout_mode = "horizontal" self._tab_titles = {} self.profile_actions = {} self.privacy_actions = {} self.password_manager = PasswordManager() self.settings = {} self.history = [] self.downloads = [] self.downloads_dialog = None self._url_suggestions = [] self.url_model = None self.url_completer = None self._drag_pos = None self._drag_from_max = False self.snap_preview = None self._snap_target = None self.snap_margin_px = 6 self._vertical_tab_anim = None self.vertical_tabs_expanded = False self.nav_toolbar = None self.load_settings() self.load_history() self.init_profiles() self.init_ui() self.load_workspaces() self.maybe_restore_last_session() def _attach_interceptor(self, profile, interceptor): if hasattr(profile, "setUrlRequestInterceptor"): profile.setUrlRequestInterceptor(interceptor) else: profile.setRequestInterceptor(interceptor) def load_settings(self): default_download_dir = str(Path.home() / "Downloads") defaults = { "download_directory": default_download_dir, "ask_download_location": True, "startup_mode": "last_session", "home_page": DEFAULT_HOME, "startup_urls": [], "theme": self.theme, "privacy_level": self.privacy_level, "cookie_policy": "force_persistent", } data = {} if SETTINGS_FILE.exists(): try: with SETTINGS_FILE.open("r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): data = {} except Exception: data = {} merged = dict(defaults) merged.update(data) self.settings = merged self.theme = self.settings.get("theme", self.theme) self.privacy_level = self.settings.get("privacy_level", self.privacy_level) def save_settings(self): self.settings["theme"] = self.theme self.settings["privacy_level"] = self.privacy_level try: with SETTINGS_FILE.open("w", encoding="utf-8") as f: json.dump(self.settings, f, indent=2) except Exception: pass def load_history(self): self.history = [] if HISTORY_FILE.exists(): try: with HISTORY_FILE.open("r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list): self.history = data except Exception: self.history = [] self.build_url_suggestions() def append_history_item(self, url: str, title: str): if not url: return try: ts = datetime.utcnow().isoformat() except Exception: ts = "" entry = {"url": url, "title": title or url, "timestamp": ts} self.history.append(entry) try: with HISTORY_FILE.open("w", encoding="utf-8") as f: json.dump(self.history, f, indent=2) except Exception: pass self.add_url_suggestion(url) def build_url_suggestions(self): if not hasattr(self, "_url_suggestions"): self._url_suggestions = [] suggestions = set(self._url_suggestions) for entry in self.history: url = entry.get("url") or "" if not url: continue suggestions.add(url) try: parsed = urlparse(url) host = parsed.netloc scheme = parsed.scheme except Exception: host = "" scheme = "" if host: suggestions.add(host) if scheme: suggestions.add(f"{scheme}://{host}") self._url_suggestions = sorted(suggestions) if self.url_model is not None: self.url_model.setStringList(self._url_suggestions) def add_url_suggestion(self, value: str): if not value: return if not hasattr(self, "_url_suggestions"): self._url_suggestions = [] changed = False if value not in self._url_suggestions: self._url_suggestions.append(value) changed = True try: parsed = urlparse(value) host = parsed.netloc scheme = parsed.scheme except Exception: host = "" scheme = "" if host and host not in self._url_suggestions: self._url_suggestions.append(host) changed = True if host and scheme: full = f"{scheme}://{host}" if full not in self._url_suggestions: self._url_suggestions.append(full) changed = True if changed and self.url_model is not None: self.url_model.setStringList(sorted(self._url_suggestions)) def init_profiles(self): kids_whitelist = { "khanacademy.org", "pbskids.org", "kids.nationalgeographic.com", "nationalgeographic.com", "nasa.gov", "kids.nasa.gov", "code.org", } self.default_profile = QWebEngineProfile("default_profile", self) self.default_profile.setHttpCacheType( QWebEngineProfile.HttpCacheType.DiskHttpCache ) self.default_interceptor = PrivacyRequestInterceptor( level=self.privacy_level, mode="default", parent=self ) self._attach_interceptor(self.default_profile, self.default_interceptor) self.private_profile = QWebEngineProfile("private_profile", self) self.private_profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies ) self.private_profile.setHttpCacheType( QWebEngineProfile.HttpCacheType.MemoryHttpCache ) self.private_interceptor = PrivacyRequestInterceptor( level=self.privacy_level, mode="private", parent=self ) self._attach_interceptor(self.private_profile, self.private_interceptor) self.kids_profile = QWebEngineProfile("kids_profile", self) self.kids_profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies ) self.kids_profile.setHttpCacheType( QWebEngineProfile.HttpCacheType.MemoryHttpCache ) self.kids_interceptor = PrivacyRequestInterceptor( level=self.privacy_level, mode="kids", kids_whitelist=kids_whitelist, parent=self ) self._attach_interceptor(self.kids_profile, self.kids_interceptor) ua = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ) for p in (self.default_profile, self.private_profile, self.kids_profile): try: p.setHttpUserAgent(ua) except Exception: pass self.apply_cookie_policy_from_settings() self.default_profile.downloadRequested.connect(self.on_download_requested) self.private_profile.downloadRequested.connect(self.on_download_requested) self.kids_profile.downloadRequested.connect(self.on_download_requested) self.profiles = { "Default": self.default_profile, "Private": self.private_profile, "Kids": self.kids_profile, } def apply_cookie_policy_from_settings(self): policy = self.settings.get("cookie_policy", "force_persistent") try: if policy == "allow": self.default_profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.AllowPersistentCookies ) elif policy in ("session_only", "block"): self.default_profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies ) else: self.default_profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies ) except Exception: pass def clear_all_cookies(self): for profile in (self.default_profile, self.private_profile, self.kids_profile): try: profile.cookieStore().deleteAllCookies() except Exception: pass def on_vertical_tab_close_requested(self, row: int): if 0 <= row < self.tab_widget.count(): self.close_tab(row) def on_vertical_tab_duplicate_requested(self, row: int): self.duplicate_tab(row) def on_vertical_tab_close_others_requested(self, row: int): self.close_other_tabs(row) def duplicate_tab(self, index: int): if not (0 <= index < self.tab_widget.count()): return tab = self.tab_widget.widget(index) if not isinstance(tab, QWebEngineView): return url = tab.url() self.new_tab(url) def close_other_tabs(self, keep_index: int): for i in range(self.tab_widget.count() - 1, -1, -1): if i != keep_index: self.close_tab(i) def set_privacy_level(self, level: str): self.privacy_level = level self.default_interceptor.set_level(level) self.private_interceptor.set_level(level) self.kids_interceptor.set_level(level) self.save_settings() def current_profile(self): return self.profiles.get(self.current_profile_name, self.default_profile) def init_ui(self): self.tab_widget = QTabWidget(self) self.tab_widget.setTabsClosable(True) self.tab_widget.setMovable(True) self.tab_widget.currentChanged.connect(self.on_current_tab_changed) self.tab_widget.tabCloseRequested.connect(self.close_tab) self.default_tab_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) self.tab_widget.tabBar().tabMoved.connect( lambda _from, _to: self.rebuild_tab_titles_mapping() ) self.vertical_tab_list = VerticalTabList(self) self.vertical_tab_list.setObjectName("verticalTabList") self.vertical_tab_list.setIconSize(QSize(16, 16)) self.vertical_tab_list.setVerticalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.vertical_tab_list.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.vertical_tab_list.setSpacing(2) self.vertical_tab_list.currentRowChanged.connect(self.on_vertical_tab_row_changed) self.vertical_tab_list.tab_close_requested.connect(self.on_vertical_tab_close_requested) self.vertical_tab_list.tab_duplicate_requested.connect(self.on_vertical_tab_duplicate_requested) self.vertical_tab_list.tab_close_others_requested.connect(self.on_vertical_tab_close_others_requested) self.vertical_tab_list.tab_reorder_requested.connect(self.on_vertical_tab_reorder_requested) self.vertical_tab_container = QWidget(self) v_layout = QVBoxLayout(self.vertical_tab_container) v_layout.setContentsMargins(0, 0, 0, 0) v_layout.setSpacing(0) v_layout.addWidget(self.vertical_tab_list) bottom_layout = QHBoxLayout() bottom_layout.addStretch() self.vertical_new_tab_button = QToolButton(self.vertical_tab_container) self.vertical_new_tab_button.setText("+") self.vertical_new_tab_button.setToolTip("New Tab") self.vertical_new_tab_button.setFixedSize(24, 24) self.vertical_new_tab_button.clicked.connect(lambda: self.new_tab()) bottom_layout.addWidget(self.vertical_new_tab_button) bottom_layout.addStretch() v_layout.addLayout(bottom_layout) self.vertical_tabs_collapsed_width = 52 self.vertical_tabs_expanded_width = 240 self.vertical_tab_container.setMinimumWidth(self.vertical_tabs_collapsed_width) self.vertical_tab_container.setMaximumWidth(self.vertical_tabs_collapsed_width) self.vertical_tab_container.hide() self.vertical_tab_container.installEventFilter(self) central = QWidget(self) central_layout = QHBoxLayout(central) central_layout.setContentsMargins(0, 0, 0, 0) central_layout.setSpacing(0) central_layout.addWidget(self.vertical_tab_container) central_layout.addWidget(self.tab_widget) self.setCentralWidget(central) self.init_toolbar() self.set_tab_layout("vertical") self.init_statusbar() self.apply_theme() self.init_shortcuts() def init_toolbar(self): toolbar = QToolBar("Navigation", self) toolbar.setObjectName("navBar") toolbar.setMovable(False) self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) self.nav_toolbar = toolbar self.title_label = QLabel("Web Browser", self) toolbar.addWidget(self.title_label) toolbar.addSeparator() self.back_action = QAction("←", self) self.back_action.setToolTip("Back") self.back_action.triggered.connect(self.go_back) toolbar.addAction(self.back_action) self.forward_action = QAction("→", self) self.forward_action.setToolTip("Forward") self.forward_action.triggered.connect(self.go_forward) toolbar.addAction(self.forward_action) self.reload_action = QAction("⟳", self) self.reload_action.setToolTip("Reload") self.reload_action.triggered.connect(self.reload_page) toolbar.addAction(self.reload_action) self.home_action = QAction("🏠", self) self.home_action.setToolTip("Home") self.home_action.triggered.connect(self.go_home) toolbar.addAction(self.home_action) toolbar.addSeparator() self.address_bar = QLineEdit(self) self.address_bar.setObjectName("addressBar") self.address_bar.setPlaceholderText("Enter URL or search with DuckDuckGo…") self.address_bar.returnPressed.connect(self.load_address) self.address_bar.setMaximumWidth(700) toolbar.addWidget(self.address_bar) self.url_model = QStringListModel(self._url_suggestions, self.address_bar) self.url_completer = QCompleter(self.url_model, self.address_bar) self.url_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self.url_completer.setFilterMode(Qt.MatchFlag.MatchContains) self.url_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.address_bar.setCompleter(self.url_completer) toolbar.addSeparator() new_tab_action = QAction("+", self) new_tab_action.setToolTip("New Tab (Ctrl+T)") new_tab_action.triggered.connect(lambda: self.new_tab()) toolbar.addAction(new_tab_action) self.downloads_action = QAction("↓", self) self.downloads_action.setToolTip("Downloads") self.downloads_action.triggered.connect(self.open_downloads_dialog) toolbar.addAction(self.downloads_action) self.doc_editor_action = QAction("📝", self) self.doc_editor_action.setToolTip("Open document editor") self.doc_editor_action.triggered.connect(self.open_document_editor) toolbar.addAction(self.doc_editor_action) self.sheet_editor_action = QAction("📊", self) self.sheet_editor_action.setToolTip("Open sheet editor") self.sheet_editor_action.triggered.connect(self.open_sheet_editor) toolbar.addAction(self.sheet_editor_action) self.history_action = QAction("🕑", self) self.history_action.setToolTip("History") self.history_action.triggered.connect(self.open_history_dialog) toolbar.addAction(self.history_action) self.password_button = QToolButton(self) self.password_button.setText("🔑") self.password_button.setToolTip("Passwords for this site") self.password_button.clicked.connect(self.open_password_dialog) toolbar.addWidget(self.password_button) self.search_tabs_action = QAction("Search Tabs…", self) self.search_tabs_action.setToolTip("Search open tabs") self.search_tabs_action.triggered.connect(self.open_tab_search) self.search_tabs_action.setEnabled(False) self.workspace_save_action = QAction("Save Workspace…", self) self.workspace_save_action.triggered.connect(self.save_workspace_dialog) self.workspace_load_action = QAction("Load Workspace…", self) self.workspace_load_action.triggered.connect(self.load_workspace_dialog) self.command_palette_action = QAction("Command Palette…", self) self.command_palette_action.triggered.connect(self.open_command_palette) self.layout_horizontal_action = QAction("Horizontal Tabs", self) self.layout_horizontal_action.setCheckable(True) self.layout_vertical_action = QAction("Vertical Tabs", self) self.layout_vertical_action.setCheckable(True) self.layout_horizontal_action.setChecked(True) self.layout_horizontal_action.triggered.connect( lambda: self.set_tab_layout("horizontal") ) self.layout_vertical_action.triggered.connect( lambda: self.set_tab_layout("vertical") ) for name in ("Default", "Private", "Kids"): act = QAction(name, self) act.setCheckable(True) act.triggered.connect(lambda checked, n=name: self.set_profile(n)) self.profile_actions[name] = act self.profile_actions["Default"].setChecked(True) for label, level in (("Max", "max"), ("Moderate", "moderate"), ("Off", "off")): act = QAction(label, self) act.setCheckable(True) act.triggered.connect(lambda checked, l=level: self.set_privacy_from_menu(l)) self.privacy_actions[level] = act self.privacy_actions["max"].setChecked(True) self.theme_dark_action = QAction("Dark", self) self.theme_dark_action.setCheckable(True) self.theme_dark_action.setChecked(self.theme == "dark") self.theme_dark_action.triggered.connect(lambda: self.set_theme("dark")) self.theme_light_action = QAction("Light", self) self.theme_light_action.setCheckable(True) self.theme_light_action.setChecked(self.theme == "light") self.theme_light_action.triggered.connect(lambda: self.set_theme("light")) menu_button = QToolButton(self) menu_button.setText("⋮") menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) menu = QMenu(menu_button) layout_menu = menu.addMenu("Tab Layout") layout_menu.addAction(self.layout_horizontal_action) layout_menu.addAction(self.layout_vertical_action) profile_menu = menu.addMenu("Profile") profile_menu.addAction(self.profile_actions["Default"]) profile_menu.addAction(self.profile_actions["Private"]) profile_menu.addAction(self.profile_actions["Kids"]) privacy_menu = menu.addMenu("Privacy") privacy_menu.addAction(self.privacy_actions["max"]) privacy_menu.addAction(self.privacy_actions["moderate"]) privacy_menu.addAction(self.privacy_actions["off"]) theme_menu = menu.addMenu("Theme") theme_menu.addAction(self.theme_dark_action) theme_menu.addAction(self.theme_light_action) menu.addSeparator() menu.addAction(self.downloads_action) menu.addAction(self.history_action) menu.addAction(self.search_tabs_action) menu.addSeparator() menu.addAction(self.workspace_save_action) menu.addAction(self.workspace_load_action) settings_menu = menu.addMenu("Settings") self.delete_data_action = QAction("Delete browsing data…", self) self.delete_data_action.triggered.connect(self.delete_browsing_data) settings_menu.addAction(self.delete_data_action) self.manage_cookies_action = QAction("Manage cookies…", self) self.manage_cookies_action.triggered.connect(self.manage_cookies) settings_menu.addAction(self.manage_cookies_action) self.download_settings_action = QAction("Download settings…", self) self.download_settings_action.triggered.connect(self.open_download_settings) settings_menu.addAction(self.download_settings_action) self.startup_settings_action = QAction("Startup pages…", self) self.startup_settings_action.triggered.connect(self.open_startup_settings) settings_menu.addAction(self.startup_settings_action) settings_menu.addSeparator() self.default_browser_action = QAction("Make default browser…", self) self.default_browser_action.triggered.connect(self.make_default_browser) settings_menu.addAction(self.default_browser_action) self.internet_options_action = QAction("Internet Options (Windows)…", self) self.internet_options_action.triggered.connect(self.open_internet_options) settings_menu.addAction(self.internet_options_action) menu.addSeparator() menu.addAction(self.command_palette_action) self.main_menu = menu menu_button.setMenu(menu) toolbar.addWidget(menu_button) self.toolbar_spacer = QWidget(self) self.toolbar_spacer.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred ) toolbar.addWidget(self.toolbar_spacer) self.min_btn = QToolButton(self) self.min_btn.setText("–") self.min_btn.setToolTip("Minimize") self.min_btn.clicked.connect(self.showMinimized) toolbar.addWidget(self.min_btn) self.max_btn = QToolButton(self) self.max_btn.setText("□") self.max_btn.setToolTip("Maximize / Restore") self.max_btn.clicked.connect(self.toggle_max_restore) toolbar.addWidget(self.max_btn) self.close_btn = QToolButton(self) self.close_btn.setText("✕") self.close_btn.setToolTip("Close") self.close_btn.clicked.connect(self.close) toolbar.addWidget(self.close_btn) self.build_url_suggestions() toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) toolbar.customContextMenuRequested.connect(self.show_toolbar_menu) toolbar.installEventFilter(self) def toggle_max_restore(self): if self.isMaximized(): self.showNormal() else: self.showMaximized() def show_toolbar_menu(self, pos): if self.main_menu is None: return global_pos = self.nav_toolbar.mapToGlobal(pos) self.main_menu.exec(global_pos) def init_statusbar(self): status = QStatusBar(self) self.setStatusBar(status) self.status_label = QLabel("Idle", self) status.addWidget(self.status_label) self.cpu_label = ClickableLabel("CPU: N/A", self) self.ram_label = ClickableLabel("RAM: N/A", self) self.disk_label = ClickableLabel("Disk: N/A", self) status.addPermanentWidget(self.cpu_label) status.addPermanentWidget(self.ram_label) status.addPermanentWidget(self.disk_label) self.cpu_label.clicked.connect(self.show_cpu_details) self.ram_label.clicked.connect(self.show_ram_details) self.disk_label.clicked.connect(self.show_disk_details) if psutil is not None: self.cpu_timer = QTimer(self) self.cpu_timer.timeout.connect(self.update_cpu_usage) self.cpu_timer.start(2000) def apply_theme(self): if self.theme == "light": self.setStyleSheet(LIGHT_STYLESHEET) else: self.setStyleSheet(DARK_STYLESHEET) def init_shortcuts(self): act_new_tab = QAction(self) act_new_tab.setShortcut("Ctrl+T") act_new_tab.triggered.connect(lambda: self.new_tab()) self.addAction(act_new_tab) def new_tab(self, url: QUrl = None): profile = self.current_profile() tab = BrowserTab(profile, self) tab.new_tab_requested.connect(self.new_tab_from_view) idx = self.tab_widget.addTab(tab, "New Tab") self._tab_titles[idx] = "New Tab" self.tab_widget.setCurrentIndex(idx) self.connect_tab_signals(tab) if url is None: if self.current_profile_name == "Kids": url = QUrl(KIDS_HOME) else: home_url = self.settings.get("home_page") or DEFAULT_HOME url = QUrl(home_url) tab.setUrl(url) self.apply_tab_title(idx, "New Tab") self.update_tab_search_action() return tab def new_tab_from_view(self, view: BrowserTab): idx = self.tab_widget.addTab(view, "New Tab") self._tab_titles[idx] = "New Tab" self.tab_widget.setCurrentIndex(idx) self.connect_tab_signals(view) self.apply_tab_title(idx, "New Tab") self.update_tab_search_action() return view def connect_tab_signals(self, tab: BrowserTab): tab.loadStarted.connect(lambda: self.on_tab_load_started(tab)) tab.loadFinished.connect(lambda ok: self.on_tab_load_finished(tab, ok)) tab.titleChanged.connect(lambda title: self.update_tab_title(tab, title)) tab.urlChanged.connect(lambda url: self.update_url_bar_if_current(tab, url)) tab.iconChanged.connect(lambda icon: self.update_tab_icon(tab, icon)) def on_vertical_tab_reorder_requested(self, from_row: int, to_row: int): count = self.tab_widget.count() if from_row == to_row or not (0 <= from_row < count and 0 <= to_row < count): return tab = self.tab_widget.widget(from_row) text = self.tab_widget.tabText(from_row) icon = self.tab_widget.tabIcon(from_row) self.tab_widget.removeTab(from_row) new_index = self.tab_widget.insertTab(to_row, tab, icon, text) self.tab_widget.setCurrentIndex(new_index) self.rebuild_tab_titles_mapping() def on_vertical_tab_row_changed(self, row: int): if row < 0 or row >= self.tab_widget.count(): return if self.tab_widget.currentIndex() != row: self.tab_widget.setCurrentIndex(row) def expand_vertical_tabs(self): if self.vertical_tabs_expanded: return self.vertical_tabs_expanded = True self.animate_vertical_tabs_width(self.vertical_tabs_expanded_width) def collapse_vertical_tabs(self): if not self.vertical_tabs_expanded: return self.vertical_tabs_expanded = False self.animate_vertical_tabs_width(self.vertical_tabs_collapsed_width) def animate_vertical_tabs_width(self, target_width: int): if self._vertical_tab_anim is not None: self._vertical_tab_anim.stop() anim = QPropertyAnimation(self.vertical_tab_container, b"maximumWidth", self) anim.setDuration(150) anim.setStartValue(self.vertical_tab_container.width()) anim.setEndValue(target_width) self._vertical_tab_anim = anim anim.start() self.vertical_tab_container.setMinimumWidth(target_width) def eventFilter(self, obj, event): if obj is getattr(self, "vertical_tab_container", None) and self.tab_layout_mode == "vertical": if event.type() == QEvent.Type.Enter: self.expand_vertical_tabs() elif event.type() == QEvent.Type.Leave: self.collapse_vertical_tabs() if obj is getattr(self, "nav_toolbar", None): if ( event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton ): child = self.nav_toolbar.childAt(event.position().toPoint()) if child is None or child is self.toolbar_spacer: self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() self._drag_from_max = self.isMaximized() self._snap_target = None self._hide_snap_preview() return True elif ( event.type() == QEvent.Type.MouseMove and self._drag_pos is not None and event.buttons() & Qt.MouseButton.LeftButton ): global_pos = event.globalPosition().toPoint() if self._drag_from_max and self.isMaximized(): self._drag_from_max = False old_offset = self._drag_pos self.showNormal() new_top_left = global_pos - old_offset self.move(new_top_left) self._drag_pos = old_offset else: self.move(global_pos - self._drag_pos) screen = self._screen_for_pos(global_pos) if screen is not None: screen_geo = screen.availableGeometry() else: screen_geo = self.geometry() snap_margin = self.snap_margin_px target = None rect = None if global_pos.y() <= screen_geo.top() + snap_margin: target = "top" rect = screen_geo elif global_pos.x() <= screen_geo.left() + snap_margin: target = "left" rect = screen_geo rect.setWidth(screen_geo.width() // 2) elif global_pos.x() >= screen_geo.right() - snap_margin: target = "right" half_width = screen_geo.width() // 2 rect = screen_geo rect.setLeft(screen_geo.right() - half_width) rect.setWidth(half_width) if target != self._snap_target: self._snap_target = target if rect is not None: self._show_snap_preview(rect) else: self._hide_snap_preview() return True elif ( event.type() == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton ): if self._drag_pos is not None: global_pos = event.globalPosition().toPoint() screen = self._screen_for_pos(global_pos) if screen is not None: screen_geo = screen.availableGeometry() else: screen_geo = self.geometry() if self._snap_target == "top": handle = self.windowHandle() if handle is not None and screen is not None: try: handle.setScreen(screen) except Exception: pass self.showMaximized() elif self._snap_target == "left": self.showNormal() self.resize(int(screen_geo.width() / 2), screen_geo.height()) self.move(screen_geo.left(), screen_geo.top()) elif self._snap_target == "right": self.showNormal() half_width = int(screen_geo.width() / 2) self.resize(half_width, screen_geo.height()) self.move(screen_geo.right() - half_width, screen_geo.top()) self._hide_snap_preview() self._snap_target = None self._drag_pos = None self._drag_from_max = False return True return super().eventFilter(obj, event) def sync_vertical_tabs(self): self.vertical_tab_list.blockSignals(True) self.vertical_tab_list.clear() item_height = 34 for i in range(self.tab_widget.count()): title = self._tab_titles.get(i, self.tab_widget.tabText(i) or "New Tab") icon = self.tab_widget.tabIcon(i) if icon.isNull(): tab = self.tab_widget.widget(i) if isinstance(tab, QWebEngineView): icon = tab.icon() if icon.isNull(): icon = self.default_tab_icon item = QListWidgetItem() item.setSizeHint(QSize(0, item_height)) self.vertical_tab_list.addItem(item) row = QWidget(self.vertical_tab_list) row.setMinimumHeight(item_height) row.setMaximumHeight(item_height) layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 8, 14) layout.setSpacing(6) icon_label = QLabel(row) icon_label.setFixedSize(20, 20) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) if not icon.isNull(): icon_label.setPixmap(icon.pixmap(16, 16)) title_label = QLabel(title, row) title_label.setObjectName("verticalTabTitleLabel") close_btn = QToolButton(row) close_btn.setText("✕") close_btn.setFixedSize(15, 15) close_btn.setStyleSheet( "QToolButton{border:none;padding:0;font-weight:bold;color:#fca5a5;}" "QToolButton:hover{background-color:rgba(0,0,0,0.2);border-radius:9px;color:#fee2e2;}" ) close_btn.clicked.connect( lambda _, row_index=i: self.vertical_tab_list.tab_close_requested.emit(row_index) ) layout.addWidget(icon_label) layout.addWidget(title_label, 1) layout.addWidget(close_btn) self.vertical_tab_list.setItemWidget(item, row) self.vertical_tab_list.setCurrentRow(self.tab_widget.currentIndex()) self.vertical_tab_list.blockSignals(False) def close_tab(self, index: int): widget = self.tab_widget.widget(index) if widget: widget.deleteLater() self.tab_widget.removeTab(index) self._tab_titles.pop(index, None) if self.tab_widget.count() == 0: self.new_tab() self.rebuild_tab_titles_mapping() self.update_tab_search_action() def close_current_tab(self): idx = self.tab_widget.currentIndex() if idx >= 0: self.close_tab(idx) def current_tab(self): return self.tab_widget.currentWidget() def on_current_tab_changed(self, index: int): tab = self.current_tab() if not tab: return self.address_bar.setText(tab.url().toString()) self.vertical_tab_list.blockSignals(True) self.vertical_tab_list.setCurrentRow(index) self.vertical_tab_list.blockSignals(False) def apply_tab_title(self, idx: int, title: str): real_title = title if title else "New Tab" self._tab_titles[idx] = real_title self.tab_widget.setTabText(idx, real_title) self.tab_widget.setTabToolTip(idx, real_title) self.sync_vertical_tabs() def rebuild_tab_titles_mapping(self): new_titles = {} for i in range(self.tab_widget.count()): old_title = self._tab_titles.get(i, self.tab_widget.tabText(i) or "New Tab") new_titles[i] = old_title self.apply_tab_title(i, old_title) self._tab_titles = new_titles def update_tab_title(self, tab: BrowserTab, title: str): idx = self.tab_widget.indexOf(tab) if idx >= 0: self.apply_tab_title(idx, title) if tab is self.current_tab(): self.setWindowTitle(f"{title} - Web Browser") def update_tab_icon(self, tab: BrowserTab, icon): idx = self.tab_widget.indexOf(tab) if idx >= 0: if icon is None or icon.isNull(): icon = self.default_tab_icon self.tab_widget.setTabIcon(idx, icon) self.sync_vertical_tabs() def update_url_bar_if_current(self, tab: BrowserTab, url: QUrl): if tab is self.current_tab(): self.address_bar.setText(url.toString()) def on_tab_load_started(self, tab: BrowserTab): if tab is self.current_tab(): self.status_label.setText("Loading…") def on_tab_load_finished(self, tab: BrowserTab, ok: bool): if tab is self.current_tab(): self.status_label.setText("Done" if ok else "Error loading page") if ok: url = tab.url() if url.isValid(): scheme = url.scheme().lower() if scheme in ("http", "https"): title = tab.title() self.append_history_item(url.toString(), title) def update_tab_search_action(self): self.search_tabs_action.setEnabled(self.tab_widget.count() >= 10) def load_address(self): text = self.address_bar.text().strip() if not text: return if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", text): url = QUrl(text) elif "." in text and " " not in text: url = QUrl("http://" + text) else: encoded = QUrl.toPercentEncoding(text) encoded_str = bytes(encoded).decode("ascii", errors="ignore") url = QUrl(SEARCH_ENGINE_URL.format(encoded_str)) self.add_url_suggestion(text) tab = self.current_tab() if tab: tab.setUrl(url) def go_back(self): tab = self.current_tab() if tab: tab.back() def go_forward(self): tab = self.current_tab() if tab: tab.forward() def reload_page(self): tab = self.current_tab() if tab: tab.reload() def go_home(self): tab = self.current_tab() if not tab: return if self.current_profile_name == "Kids": tab.setUrl(QUrl(KIDS_HOME)) else: home_url = self.settings.get("home_page") or DEFAULT_HOME tab.setUrl(QUrl(home_url)) def set_tab_layout(self, mode: str): if mode == self.tab_layout_mode: return self.tab_layout_mode = mode if mode == "vertical": self.vertical_tab_container.show() self.tab_widget.tabBar().hide() self.collapse_vertical_tabs() else: self.vertical_tab_container.hide() self.tab_widget.tabBar().show() self.layout_horizontal_action.setChecked(mode == "horizontal") self.layout_vertical_action.setChecked(mode == "vertical") self.sync_vertical_tabs() def set_profile(self, name: str): self.current_profile_name = name for n, act in self.profile_actions.items(): act.setChecked(n == name) self.status_label.setText(f"Profile: {name}") msg = ( "New tabs will use the '{}' profile.\nExisting tabs keep their current profile." ).format(name) self.show_info("Profile changed", msg) def set_privacy_from_menu(self, level: str): self.set_privacy_level(level) for lvl, act in self.privacy_actions.items(): act.setChecked(lvl == level) self.status_label.setText(f"Privacy: {level.upper()}") def set_theme(self, theme: str): if theme not in ("dark", "light"): return self.theme = theme self.theme_dark_action.setChecked(theme == "dark") self.theme_light_action.setChecked(theme == "light") self.apply_theme() self.save_settings() def _format_bytes(self, value): try: value = float(value) except Exception: return str(value) units = ["B", "KB", "MB", "GB", "TB", "PB"] idx = 0 while value >= 1024.0 and idx < len(units) - 1: value /= 1024.0 idx += 1 return f"{value:.1f} {units[idx]}" def _ensure_snap_preview(self): if self.snap_preview is None: self.snap_preview = QRubberBand(QRubberBand.Shape.Rectangle, None) self.snap_preview.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents ) self.snap_preview.setWindowFlags( self.snap_preview.windowFlags() | Qt.WindowType.WindowStaysOnTopHint ) def _show_snap_preview(self, rect): if rect is None: self._hide_snap_preview() return self._ensure_snap_preview() self.snap_preview.setGeometry(rect) self.snap_preview.show() def _hide_snap_preview(self): if self.snap_preview is not None: self.snap_preview.hide() def _screen_for_pos(self, global_pos: QPoint): screen = None try: if hasattr(QApplication, "screenAt"): screen = QApplication.screenAt(global_pos) except Exception: screen = None if screen is None: try: handle = self.windowHandle() if handle is not None: screen = handle.screen() except Exception: screen = None if screen is None: screen = QApplication.primaryScreen() return screen def show_cpu_details(self): if psutil is None: self.show_info( "CPU details", "psutil is not installed.\n\nInstall it with:\n pip install psutil" ) return dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) dlg.setMinimumWidth(420) layout = QVBoxLayout(dlg) title = QLabel("CPU details", dlg) title.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title) overall = psutil.cpu_percent(interval=0.2) per_core = psutil.cpu_percent(interval=None, percpu=True) freq = psutil.cpu_freq() lines = [] lines.append(f"Overall usage: {overall:.1f}%") if per_core: lines.append("") lines.append("Per-core usage:") for i, v in enumerate(per_core): lines.append(f" Core {i}: {v:.1f}%") if freq: lines.append("") lines.append(f"Current frequency: {freq.current / 1000:.2f} GHz") if freq.min: lines.append(f"Min frequency: {freq.min / 1000:.2f} GHz") if freq.max: lines.append(f"Max frequency: {freq.max / 1000:.2f} GHz") body = QLabel("\n".join(lines), dlg) body.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) layout.addWidget(body) btn_row = QHBoxLayout() btn_row.addStretch() close_btn = QPushButton("Close", dlg) close_btn.clicked.connect(dlg.accept) btn_row.addWidget(close_btn) layout.addLayout(btn_row) dlg.exec() def show_ram_details(self): if psutil is None: self.show_info("Memory details", "psutil is not available; cannot read memory stats.") return dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) dlg.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True) layout = QVBoxLayout(dlg) title = QLabel("Memory details", dlg) title.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title) text = QPlainTextEdit(dlg) text.setReadOnly(True) lines = [] try: mem = psutil.virtual_memory() swap = psutil.swap_memory() lines.append("Physical memory:") lines.append(" Total : {}".format(self._format_bytes(mem.total))) lines.append(" Used : {} ({:.1f}%)".format( self._format_bytes(mem.used), mem.percent )) lines.append(" Free/Available: {}".format(self._format_bytes(mem.available))) lines.append("") lines.append("Swap:") lines.append(" Total : {}".format(self._format_bytes(swap.total))) lines.append(" Used : {} ({:.1f}%)".format( self._format_bytes(swap.used), swap.percent )) lines.append(" Free : {}".format(self._format_bytes(swap.free))) lines.append("") lines.append("Top processes by memory:") procs = [] for p in psutil.process_iter(attrs=["name", "memory_info"]): info = p.info rss = getattr(info.get("memory_info"), "rss", 0) procs.append((info.get("name") or "?", rss)) procs.sort(key=lambda x: x[1], reverse=True) for name, rss in procs[:8]: lines.append(" {:25s} {:>9}".format(name[:25], self._format_bytes(rss))) except Exception as e: lines.append("Error reading memory stats: {}".format(e)) text.setPlainText("\n".join(lines)) layout.addWidget(text) btn_row = QHBoxLayout() btn_row.addStretch() close_btn = QPushButton("Close", dlg) close_btn.clicked.connect(dlg.accept) btn_row.addWidget(close_btn) layout.addLayout(btn_row) dlg.resize(520, 360) dlg.exec() def show_disk_details(self): if psutil is None: self.show_info("Disk details", "psutil is not available; cannot read disk stats.") return dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) dlg.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True) layout = QVBoxLayout(dlg) title = QLabel("Disk / storage details", dlg) title.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title) text = QPlainTextEdit(dlg) text.setReadOnly(True) lines = [] try: parts = psutil.disk_partitions(all=False) if not parts: lines.append("No disk partitions found.") for part in parts: try: usage = psutil.disk_usage(part.mountpoint) except Exception: continue lines.append("{} ({})".format(part.device, part.mountpoint)) lines.append(" File system : {}".format(part.fstype or "?")) lines.append(" Total : {}".format(self._format_bytes(usage.total))) lines.append(" Used : {} ({:.1f}%)".format( self._format_bytes(usage.used), usage.percent )) lines.append(" Free : {}".format(self._format_bytes(usage.free))) lines.append("") except Exception as e: lines.append("Error reading disk stats: {}".format(e)) text.setPlainText("\n".join(lines)) layout.addWidget(text) btn_row = QHBoxLayout() btn_row.addStretch() close_btn = QPushButton("Close", dlg) close_btn.clicked.connect(dlg.accept) btn_row.addWidget(close_btn) layout.addLayout(btn_row) dlg.resize(520, 360) dlg.exec() def update_cpu_usage(self): if psutil is None: return try: cpu = psutil.cpu_percent(interval=None) mem = psutil.virtual_memory() try: disk_usage = psutil.disk_usage("/") except Exception: root = Path.home().anchor or "/" disk_usage = psutil.disk_usage(root) self.cpu_label.setText(f"CPU: {cpu:.1f}%") self.ram_label.setText(f"RAM: {mem.percent:.1f}%") self.disk_label.setText(f"Disk: {disk_usage.percent:.1f}%") except Exception: self.cpu_label.setText("CPU: N/A") self.ram_label.setText("RAM: N/A") self.disk_label.setText("Disk: N/A") def load_workspaces(self): if WORKSPACES_FILE.exists(): try: with WORKSPACES_FILE.open("r", encoding="utf-8") as f: self.workspaces = json.load(f) except Exception: self.workspaces = {} else: self.workspaces = {} def save_workspaces_file(self): try: with WORKSPACES_FILE.open("w", encoding="utf-8") as f: json.dump(self.workspaces, f, indent=2) except Exception: self.show_warning("Error", "Unable to save workspaces file.") def _current_session_data(self): tabs_data = [] for i in range(self.tab_widget.count()): tab = self.tab_widget.widget(i) if isinstance(tab, QWebEngineView): tabs_data.append(tab.url().toString()) return {"tabs": tabs_data, "current_index": self.tab_widget.currentIndex()} def save_workspace_dialog(self): name, ok = QInputDialog.getText(self, "Save Workspace", "Workspace name:") if not ok or not name.strip(): return name = name.strip() self.workspaces[name] = self._current_session_data() self.save_workspaces_file() self.status_label.setText(f"Saved workspace '{name}'") def save_last_session_workspace(self): data = self._current_session_data() if not data["tabs"]: if LAST_SESSION_KEY in self.workspaces: del self.workspaces[LAST_SESSION_KEY] else: self.workspaces[LAST_SESSION_KEY] = data self.save_workspaces_file() def load_workspace_dialog(self): visible_names = [n for n in self.workspaces.keys() if n != LAST_SESSION_KEY] if not visible_names: self.show_info("Workspaces", "No workspaces saved yet.") return names = sorted(visible_names) name, ok = QInputDialog.getItem( self, "Load Workspace", "Choose workspace:", names, 0, False ) if not ok or not name: return self.load_workspace(name) def load_workspace(self, name: str, from_last_session: bool = False): data = self.workspaces.get(name) if not data: if not from_last_session: self.show_warning("Error", f"Workspace '{name}' not found.") return while self.tab_widget.count() > 0: w = self.tab_widget.widget(0) if w: w.deleteLater() self.tab_widget.removeTab(0) for url_str in data.get("tabs", []): self.new_tab(QUrl(url_str)) idx = data.get("current_index", 0) if 0 <= idx < self.tab_widget.count(): self.tab_widget.setCurrentIndex(idx) if from_last_session: self.status_label.setText("Restored last session") else: self.status_label.setText(f"Loaded workspace '{name}'") def maybe_restore_last_session(self): mode = self.settings.get("startup_mode", "last_session") if mode == "last_session": data = self.workspaces.get(LAST_SESSION_KEY) if data and data.get("tabs"): msg = ( "A previous browsing session was found.\n" "Do you want to restore your last session?" ) if self.show_question("Restore last session?", msg): self.load_workspace(LAST_SESSION_KEY, from_last_session=True) return home_url = self.settings.get("home_page") or DEFAULT_HOME if self.current_profile_name == "Kids": self.new_tab(QUrl(KIDS_HOME)) else: self.new_tab(QUrl(home_url)) return if mode == "home": home_url = self.settings.get("home_page") or DEFAULT_HOME if self.current_profile_name == "Kids": self.new_tab(QUrl(KIDS_HOME)) else: self.new_tab(QUrl(home_url)) return if mode == "urls": urls = self.settings.get("startup_urls") or [] used = False for url_str in urls: u = url_str.strip() if not u: continue if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", u): u = "http://" + u self.new_tab(QUrl(u)) used = True if used: return home_url = self.settings.get("home_page") or DEFAULT_HOME if self.current_profile_name == "Kids": self.new_tab(QUrl(KIDS_HOME)) else: self.new_tab(QUrl(home_url)) return home_url = self.settings.get("home_page") or DEFAULT_HOME if self.current_profile_name == "Kids": self.new_tab(QUrl(KIDS_HOME)) else: self.new_tab(QUrl(home_url)) def open_document_editor(self): editor = DocumentEditorWindow(self) editor.show() def open_sheet_editor(self): editor = SheetEditorWindow(self) editor.show() def open_command_palette(self): commands = { "New Tab": lambda: self.new_tab(), "Close Current Tab": self.close_current_tab, "Next Tab": self.next_tab, "Previous Tab": self.prev_tab, "Search Tabs…": self.open_tab_search, "Save Workspace…": self.save_workspace_dialog, "Load Workspace…": self.load_workspace_dialog, "Go Home": self.go_home, } dlg = CommandPalette(self, commands) dlg.exec() def next_tab(self): idx = self.tab_widget.currentIndex() count = self.tab_widget.count() if count == 0: return self.tab_widget.setCurrentIndex((idx + 1) % count) def prev_tab(self): idx = self.tab_widget.currentIndex() count = self.tab_widget.count() if count == 0: return self.tab_widget.setCurrentIndex((idx - 1) % count) def open_tab_search(self): tabs_info = [] for i in range(self.tab_widget.count()): tab = self.tab_widget.widget(i) if isinstance(tab, QWebEngineView): title = self._tab_titles.get(i, tab.title() or "New Tab") url = tab.url().toString() tabs_info.append((i, title, url)) if not tabs_info: return dlg = TabSearchDialog(self, tabs_info) if dlg.exec() == QDialog.DialogCode.Accepted and dlg.selected_index is not None: self.tab_widget.setCurrentIndex(dlg.selected_index) def open_password_dialog(self): if not self.password_manager or not self.password_manager.available: self.show_warning( "Passwords", "The secure password storage backend (keyring) is not available.\n\n" "Install it with:\n pip install keyring", ) return tab = self.current_tab() if not tab: return url = tab.url().toString() if not url: self.show_warning("Passwords", "No page is loaded.") return origin = self.password_manager.origin_for_url(url) or "(unknown origin)" dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel("Passwords for this site", dlg) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) origin_label = QLabel(origin, dlg) origin_label.setWordWrap(True) layout.addWidget(origin_label) save_title = QLabel("Save password", dlg) save_title.setStyleSheet("margin-top:8px;font-weight:bold;") layout.addWidget(save_title) user_edit = QLineEdit(dlg) user_edit.setPlaceholderText("Username / email") layout.addWidget(user_edit) pass_edit = QLineEdit(dlg) pass_edit.setPlaceholderText("Password") pass_edit.setEchoMode(QLineEdit.EchoMode.Password) layout.addWidget(pass_edit) save_buttons = QHBoxLayout() save_buttons.addStretch() save_btn = QPushButton("Save", dlg) save_buttons.addWidget(save_btn) layout.addLayout(save_buttons) existing_title = QLabel("Fill saved password", dlg) existing_title.setStyleSheet("margin-top:12px;font-weight:bold;") layout.addWidget(existing_title) usernames = self.password_manager.list_usernames(url) combo = QComboBox(dlg) combo.addItems(usernames) combo.setEnabled(bool(usernames)) layout.addWidget(combo) fill_buttons = QHBoxLayout() fill_buttons.addStretch() fill_btn = QPushButton("Fill into page", dlg) fill_btn.setEnabled(bool(usernames)) fill_buttons.addWidget(fill_btn) layout.addLayout(fill_buttons) bottom_buttons = QHBoxLayout() bottom_buttons.addStretch() close_btn = QPushButton("Close", dlg) bottom_buttons.addWidget(close_btn) layout.addLayout(bottom_buttons) def do_save(): u = user_edit.text().strip() p = pass_edit.text() if not u or not p: return ok = self.password_manager.save_password(url, u, p) if ok: existing = [combo.itemText(i) for i in range(combo.count())] if u not in existing: combo.addItem(u) combo.setEnabled(True) fill_btn.setEnabled(True) user_edit.clear() pass_edit.clear() self.status_label.setText("Password saved securely") else: self.show_warning( "Passwords", "Could not save password in the system keyring.", ) def do_fill(): username = combo.currentText() if not username: return self.fill_password_for_username(username) save_btn.clicked.connect(do_save) fill_btn.clicked.connect(do_fill) close_btn.clicked.connect(dlg.accept) dlg.resize(440, 280) dlg.exec() def _js_escape(self, s: str) -> str: return s.replace("\\", "\\\\").replace("'", "\\'") def fill_password_for_username(self, username: str): if not username: return tab = self.current_tab() if not tab: return url = tab.url().toString() password = ( self.password_manager.get_password(url, username) if self.password_manager else None ) if password is None: self.show_warning("Passwords", "No stored password found for this account.") return user_js = self._js_escape(username) pass_js = self._js_escape(password) js = ( "(function(){" "var pass=document.querySelector('input[type=\"password\"]');" "if(!pass)return false;" "var form=pass.form||document;" "var user=form.querySelector('input[type=\"email\"],input[type=\"text\"]," "input[name*=\"user\"],input[name*=\"login\"],input[name*=\"email\"]');" "if(!user){user=document.querySelector('input[type=\"text\"],input[type=\"email\"]');}" "if(!user)return false;" f"user.value='{user_js}';" f"pass.value='{pass_js}';" "var ev=new Event('input',{bubbles:true});" "user.dispatchEvent(ev);" "pass.dispatchEvent(ev);" "return true;" "})();" ) tab.page().runJavaScript(js) self.status_label.setText("Filled credentials into page") def show_info(self, title: str, text: str): dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel(title) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) text_label = QLabel(text) text_label.setWordWrap(True) layout.addWidget(text_label) btn_layout = QHBoxLayout() btn_layout.addStretch() ok_btn = QPushButton("OK", dlg) ok_btn.clicked.connect(dlg.accept) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) dlg.resize(420, 200) dlg.exec() def show_warning(self, title: str, text: str): self.show_info(title, text) def show_question(self, title: str, text: str, yes_text="Yes", no_text="No") -> bool: dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel(title) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) text_label = QLabel(text) text_label.setWordWrap(True) layout.addWidget(text_label) btn_layout = QHBoxLayout() btn_layout.addStretch() yes_btn = QPushButton(yes_text, dlg) no_btn = QPushButton(no_text, dlg) yes_btn.clicked.connect(dlg.accept) no_btn.clicked.connect(dlg.reject) btn_layout.addWidget(no_btn) btn_layout.addWidget(yes_btn) layout.addLayout(btn_layout) dlg.resize(420, 200) return dlg.exec() == QDialog.DialogCode.Accepted def on_download_requested(self, item: QWebEngineDownloadRequest): ask = bool(self.settings.get("ask_download_location", True)) base_dir = Path(self.settings.get("download_directory") or (Path.home() / "Downloads")) try: base_dir.mkdir(parents=True, exist_ok=True) except Exception: pass if ask: suggested = base_dir / item.downloadFileName() fname, _ = QFileDialog.getSaveFileName( self, "Save file as", str(suggested) ) if not fname: try: item.cancel() except Exception: pass return target_path = Path(fname) else: target_path = base_dir / item.downloadFileName() try: item.setDownloadDirectory(str(target_path.parent)) item.setDownloadFileName(target_path.name) except Exception: pass record = { "item": item, "url": item.url().toString(), "path": str(target_path), "file_name": item.downloadFileName(), "state": "Starting", "received_bytes": int(item.receivedBytes()), "total_bytes": int(item.totalBytes()), } self.downloads.append(record) index = len(self.downloads) - 1 try: item.receivedBytesChanged.connect( lambda idx=index: self.on_download_progress(idx) ) item.totalBytesChanged.connect( lambda idx=index: self.on_download_progress(idx) ) item.stateChanged.connect( lambda state, idx=index: self.on_download_state_changed(idx, state) ) except Exception: pass try: item.accept() except Exception: pass self.status_label.setText(f"Downloading {item.downloadFileName()}…") if self.downloads_dialog is not None and self.downloads_dialog.isVisible(): self.downloads_dialog.refresh() def on_download_progress(self, index): if not (0 <= index < len(self.downloads)): return rec = self.downloads[index] item = rec.get("item") if item is None: return try: received = int(item.receivedBytes()) total = int(item.totalBytes()) except Exception: received = rec.get("received_bytes", 0) total = rec.get("total_bytes", 0) rec["received_bytes"] = received rec["total_bytes"] = total if total > 0: pct = int(received * 100 / total) rec["state"] = f"{pct}%" else: rec["state"] = "Downloading" if self.downloads_dialog is not None and self.downloads_dialog.isVisible(): self.downloads_dialog.refresh() def on_download_state_changed(self, index, state): if not (0 <= index < len(self.downloads)): return rec = self.downloads[index] try: if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted: rec["state"] = "Completed" elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled: rec["state"] = "Cancelled" elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted: rec["state"] = "Interrupted" else: rec["state"] = "Downloading" except Exception: rec["state"] = "Completed" self.status_label.setText( f"Download finished: {rec.get('file_name', '')}" if rec.get("state") == "Completed" else f"Download {rec.get('state', '')}: {rec.get('file_name', '')}" ) if self.downloads_dialog is not None and self.downloads_dialog.isVisible(): self.downloads_dialog.refresh() def open_downloads_dialog(self): dlg = DownloadsDialog(self, self.downloads) self.downloads_dialog = dlg dlg.exec() self.downloads_dialog = None def open_history_dialog(self): if not self.history: self.show_info("History", "No browsing history yet.") return dlg = HistoryDialog(self, self.history) dlg.exec() def delete_browsing_data(self): if not self.history and not HISTORY_FILE.exists(): self.show_info("Delete browsing data", "No browsing history to clear.") return msg = ( "Clear all browsing history stored by this browser?\n\n" "This does not affect other browsers." ) if not self.show_question("Delete browsing data", msg): return self.history = [] try: if HISTORY_FILE.exists(): HISTORY_FILE.unlink() except Exception: pass self.status_label.setText("Browsing history cleared") def manage_cookies(self): dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel("Manage cookies", dlg) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) desc = QLabel( "Cookie settings apply to the Default profile.\n" "Private and Kids profiles already avoid persistent cookies.", dlg, ) desc.setWordWrap(True) layout.addWidget(desc) layout.addWidget(QLabel("Cookie policy:", dlg)) policy_combo = QComboBox(dlg) policy_combo.addItem("Keep cookies between sessions", "force_persistent") policy_combo.addItem("Delete cookies when browser closes", "session_only") policy_combo.addItem("Disable persistent cookies", "block") current = self.settings.get("cookie_policy", "force_persistent") idx = policy_combo.findData(current) if idx < 0: idx = 0 policy_combo.setCurrentIndex(idx) layout.addWidget(policy_combo) clear_btn = QPushButton("Clear all cookies now", dlg) layout.addWidget(clear_btn) btn_layout = QHBoxLayout() btn_layout.addStretch() cancel_btn = QPushButton("Cancel", dlg) ok_btn = QPushButton("OK", dlg) btn_layout.addWidget(cancel_btn) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) def do_clear(): if self.show_question("Clear cookies", "Delete all cookies for all profiles now?"): self.clear_all_cookies() self.status_label.setText("All cookies cleared") clear_btn.clicked.connect(do_clear) def apply_and_close(): policy = policy_combo.currentData() self.settings["cookie_policy"] = policy self.save_settings() self.apply_cookie_policy_from_settings() dlg.accept() ok_btn.clicked.connect(apply_and_close) cancel_btn.clicked.connect(dlg.reject) dlg.resize(420, 260) dlg.exec() def open_download_settings(self): dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel("Download settings", dlg) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) path_layout = QHBoxLayout() path_edit = QLineEdit(dlg) path_edit.setText( self.settings.get("download_directory") or str(Path.home() / "Downloads") ) browse_btn = QPushButton("Browse…", dlg) path_layout.addWidget(path_edit, 1) path_layout.addWidget(browse_btn) layout.addLayout(path_layout) ask_checkbox = QCheckBox("Ask for a location for every download", dlg) ask_checkbox.setChecked(bool(self.settings.get("ask_download_location", True))) layout.addWidget(ask_checkbox) btn_layout = QHBoxLayout() btn_layout.addStretch() cancel_btn = QPushButton("Cancel", dlg) ok_btn = QPushButton("OK", dlg) btn_layout.addWidget(cancel_btn) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) def browse(): base = path_edit.text().strip() or str(Path.home()) directory = QFileDialog.getExistingDirectory( self, "Choose download folder", base ) if directory: path_edit.setText(directory) browse_btn.clicked.connect(browse) def apply_and_close(): directory = path_edit.text().strip() if not directory: directory = str(Path.home()) self.settings["download_directory"] = directory self.settings["ask_download_location"] = ask_checkbox.isChecked() self.save_settings() dlg.accept() ok_btn.clicked.connect(apply_and_close) cancel_btn.clicked.connect(dlg.reject) dlg.resize(480, 220) dlg.exec() def open_startup_settings(self): dlg = QDialog(self) dlg.setModal(True) dlg.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint ) layout = QVBoxLayout(dlg) title_label = QLabel("Startup pages", dlg) title_label.setStyleSheet("font-weight:bold;font-size:14px;") layout.addWidget(title_label) layout.addWidget(QLabel("When the browser starts:", dlg)) mode_combo = QComboBox(dlg) mode_combo.addItem("Ask to restore last session", "last_session") mode_combo.addItem("Open a home page", "home") mode_combo.addItem("Open a set of pages", "urls") current_mode = self.settings.get("startup_mode", "last_session") idx = mode_combo.findData(current_mode) if idx < 0: idx = 0 mode_combo.setCurrentIndex(idx) layout.addWidget(mode_combo) layout.addWidget(QLabel("Home page URL:", dlg)) home_edit = QLineEdit(dlg) home_edit.setText(self.settings.get("home_page") or DEFAULT_HOME) layout.addWidget(home_edit) layout.addWidget(QLabel("Startup pages (one URL per line):", dlg)) pages_edit = QPlainTextEdit(dlg) startup_urls = self.settings.get("startup_urls") or [] pages_edit.setPlainText("\n".join(startup_urls)) layout.addWidget(pages_edit) use_current_btn = QPushButton("Use current tabs", dlg) layout.addWidget(use_current_btn) def fill_from_current_tabs(): urls = [] for i in range(self.tab_widget.count()): tab = self.tab_widget.widget(i) if isinstance(tab, QWebEngineView): u = tab.url().toString() if u: urls.append(u) pages_edit.setPlainText("\n".join(urls)) use_current_btn.clicked.connect(fill_from_current_tabs) btn_layout = QHBoxLayout() btn_layout.addStretch() cancel_btn = QPushButton("Cancel", dlg) ok_btn = QPushButton("OK", dlg) btn_layout.addWidget(cancel_btn) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) def apply_and_close(): mode = mode_combo.currentData() self.settings["startup_mode"] = mode home = home_edit.text().strip() or DEFAULT_HOME self.settings["home_page"] = home raw_lines = pages_edit.toPlainText().splitlines() urls = [ln.strip() for ln in raw_lines if ln.strip()] self.settings["startup_urls"] = urls self.save_settings() dlg.accept() ok_btn.clicked.connect(apply_and_close) cancel_btn.clicked.connect(dlg.reject) dlg.resize(520, 420) dlg.exec() def make_default_browser(self): if sys.platform.startswith("win"): msg = ( "Windows manages default browsers in Default Apps settings.\n\n" "Open the Default Apps settings page now?" ) if not self.show_question("Make default browser", msg): return try: os.startfile("ms-settings:defaultapps") except Exception: try: os.system("control /name Microsoft.DefaultPrograms") except Exception: self.show_warning( "Default browser", "Could not open Windows default apps settings on this system.", ) else: msg = ( "Setting the system default browser must be done in your operating " "system's settings.\n\nOpen your system's default applications " "preferences and choose this browser there." ) self.show_info("Default browser", msg) def open_internet_options(self): if not sys.platform.startswith("win"): self.show_info("Internet Options", "Internet Options is only available on Windows.") return try: os.startfile("inetcpl.cpl") except Exception: self.show_warning( "Internet Options", "Could not open Internet Options on this system." ) def closeEvent(self, event): self.save_last_session_workspace() self.save_settings() event.accept() def main(): if "--uninstall" in sys.argv: unregister_from_installed_apps() return app = QApplication(sys.argv) app.setApplicationName("Web Browser") base_path = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) icon_path = base_path / "browser.ico" if icon_path.exists(): app.setWindowIcon(QIcon(str(icon_path))) window = BrowserWindow() if icon_path.exists(): window.setWindowIcon(QIcon(str(icon_path))) window.show() register_in_installed_apps() sys.exit(app.exec()) if __name__ == "__main__": main()