Web Browser
A privacy-first Windows browser with profiles, kids mode, vertical tabs, a command palette, and a built-in password manager.
Why you’ll like it
Switch between Default, Private, and Kids modes with separate profiles and strict privacy rules.
Toggle a clean vertical tab sidebar with hover-expand, or keep the classic horizontal strip.
Securely store credentials with the system keyring and fill them into login forms in one click.
Save your current tabs as named workspaces and optionally restore your last session on startup.
Open the palette to jump tabs, save workspaces, or run browser actions without leaving the keyboard.
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
Run the EXE, pick a profile (Default, Private, or Kids), and open a few of your everyday sites.
Switch between horizontal and vertical tabs, choose dark or light theme, and tune privacy level.
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.
Hotkeys (example)
Open a new tab with your current profile.
Focus the address bar to type or search.
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)
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()