11 Commits

Author SHA1 Message Date
0a4284191b merge upstream 2025-10-04 16:59:34 +00:00
c8c45dda06 chore(readme): drop Those Awesome Guys
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-04 20:54:57 +05:00
3f9f794e6f hint icons 2025-10-04 22:12:10 +07:00
ba9d8b76d8 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:31:01 +05:00
e99c71c1f8 feat: optimize search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:29:18 +05:00
d05f2fccd6 Preloader for load games 2025-10-01 23:59:12 +03:00
baec62d1cb chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:52 +05:00
cb76961e4f feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:37 +05:00
Gitea Actions
081cd07253 chore: update steam apps list 2025-10-01T00:01:41Z 2025-10-01 00:01:42 +00:00
b5efee29ea chore: cleanup MainWindow class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-30 15:43:11 +05:00
69360f7e7e touchscreen scrolling 2025-09-28 16:04:11 +03:00
55 changed files with 12773 additions and 1144 deletions

View File

@@ -6,11 +6,15 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- Возможность скроллинга библиотеки мышью или пальцем
### Changed ### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
### Fixed ### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений - Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
### Contributors ### Contributors
@@ -29,12 +33,12 @@
### Changed ### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе - Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed ### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON - Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры - Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors ### Contributors
- @wmigor (Igor Akulov)
--- ---

View File

@@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE). Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING] > [!WARNING]

View File

@@ -217,7 +217,7 @@
}, },
{ {
"normalized_name": "watch_dogs 2", "normalized_name": "watch_dogs 2",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "zero hour", "normalized_name": "zero hour",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,96 @@
[ [
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
},
{
"normalized_title": "deus ex human revolution directors cut",
"slug": "deus-ex-human-revolution-director-s-cut"
},
{
"normalized_title": "freelancer",
"slug": "freelancer"
},
{
"normalized_title": "everspace",
"slug": "everspace"
},
{
"normalized_title": "blades of time limited",
"slug": "blades-of-time-limited-edition"
},
{
"normalized_title": "chorus",
"slug": "chorus"
},
{
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
},
{
"normalized_title": "the alters",
"slug": "the-alters"
},
{
"normalized_title": "hard reset redux",
"slug": "hard-reset-redux"
},
{
"normalized_title": "far cry 5",
"slug": "far-cry-5"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "old world",
"slug": "old-world"
},
{
"normalized_title": "witchfire",
"slug": "witchfire"
},
{
"normalized_title": "prototype",
"slug": "prototype"
},
{
"normalized_title": "mandragora whispers of the witch tree",
"slug": "mandragora-whispers-of-the-witch-tree"
},
{
"normalized_title": "grand theft auto v (gta 5)",
"slug": "grand-theft-auto-v-gta-5"
},
{
"normalized_title": "lifeless planet premier",
"slug": "lifeless-planet-premier-edition"
},
{
"normalized_title": "warcraft iii the frozen throne",
"slug": "warcraft-iii-the-frozen-throne"
},
{
"normalized_title": "star wars republic commando",
"slug": "star-wars-republic-commando"
},
{
"normalized_title": "hollow knight silksong",
"slug": "hollow-knight-silksong"
},
{
"normalized_title": "arma reforger",
"slug": "arma-reforger"
},
{
"normalized_title": "arma 3",
"slug": "arma-3"
},
{ {
"normalized_title": "astroneer", "normalized_title": "astroneer",
"slug": "astroneer" "slug": "astroneer"
@@ -195,10 +287,6 @@
"normalized_title": "slitterhead", "normalized_title": "slitterhead",
"slug": "slitterhead" "slug": "slitterhead"
}, },
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{ {
"normalized_title": "crossout", "normalized_title": "crossout",
"slug": "crossout" "slug": "crossout"

Binary file not shown.

View File

@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
class ContextMenuManager: class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQt.""" """Manages context menu actions for game management in PortProtonQt."""
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback): def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
""" """
Initialize the ContextMenuManager. Initialize the ContextMenuManager.
@@ -45,7 +45,8 @@ class ContextMenuManager:
self.theme = theme self.theme = theme
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.load_games = load_games_callback self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback self.game_library_manager = game_library_manager
self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join( self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary" "PortProtonQt", "legendary_cache", "legendary"
@@ -859,9 +860,16 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e)) _("Failed to delete custom data: {error}").format(error=str(e))
) )
# Reload games list and update grid self.update_game_grid = self.game_library_manager.remove_game_incremental
self.load_games() self.game_library_manager.remove_game_incremental(game_name, exec_line)
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add game after .desktop creation."""
if not self._check_portproton():
return
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
self.game_library_manager.add_game_incremental(game_data)
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
def add_to_menu(self, game_name, exec_line): def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications.""" """Copy the .desktop file to ~/.local/share/applications."""

View File

@@ -4,7 +4,7 @@ import re
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
) )
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
@@ -374,6 +374,9 @@ class FileExplorer(QDialog):
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click) self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu) self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
self.file_list.setHorizontalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.file_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
QScroller.grabGesture(self.file_list.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.main_layout.addWidget(self.file_list) self.main_layout.addWidget(self.file_list)
# Connect scroll signal for lazy loading # Connect scroll signal for lazy loading

View File

@@ -0,0 +1,453 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
def openGameDetailPage(
self,
name: str,
description: str,
cover_path: str | None = None,
appid: str = "",
exec_line: str = "",
controller_support: str = "",
last_launch: str = "",
formatted_playtime: str = "",
protondb_tier: str = "",
game_source: str = "",
anticheat_status: str = "",
) -> None: ...
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
def on_slider_released(self) -> None: ...
# Required attributes
searchEdit: CustomLineEdit
_last_card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
self.main_window = main_window
self.theme = theme
self.context_menu_manager: ContextMenuManager | None = context_menu_manager
self.games: list[tuple] = []
self.filtered_games: list[tuple] = []
self.game_card_cache = {}
self.pending_images = {}
self.card_width = read_card_size()
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | None = None
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._pending_update = False
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
# Scroll area for game grid
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
# Initialize update timer
self._update_timer = QTimer()
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
# Calculate initial card width
def calculate_card_width():
if self.gamesListLayout is None:
return
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
return self.gamesLibraryWidget
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.sizeSlider is None:
return
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def load_visible_images(self):
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Handles card focus events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
self.main_window.current_focused_card = card
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Handles card hover events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = card
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
def _perform_update(self):
"""Performs the actual grid update."""
if not self._pending_update:
return
self._pending_update = False
self._update_game_grid_immediate()
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
"""Schedules a game grid update with debouncing."""
if not is_filter:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
self.is_filtering = is_filter
self._pending_update = True
if self._update_timer is not None:
self._update_timer.start()
else:
self._update_game_grid_immediate()
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
return
search_text = self.main_window.searchEdit.text().strip().lower()
if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards
self._apply_filter_visibility(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
favorites = read_favorites()
sort_method = read_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
new_card_order = []
cards_to_add = []
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
self.dirty = False # Reset flag
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force geometry update so FlowLayout accounts for hidden widgets
if self.gamesListLayout is not None:
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
card = GameCard(
*game_data,
select_callback=self.main_window.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
return card
def _flush_deletions(self):
"""Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions):
card.deleteLater()
self.pending_deletions.remove(card)
def clear_layout(self, layout):
"""Clears all widgets from the layout."""
if layout is None:
return
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
if key in self.pending_images:
del self.pending_images[key]
widget.deleteLater()
def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games."""
self.games = games
self.filtered_games = self.games
self.dirty = True # Full resort needed
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self.dirty = True
self.update_game_grid()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
self.games = [g for g in self.games if (g[0], g[4]) != key]
self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
if key in self.game_card_cache and self.gamesListLayout is not None:
card = self.game_card_cache.pop(key)
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer deleteLater
if key in self.pending_images:
del self.pending_images[key]
self.dirty = True
self.update_game_grid()
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True)

View File

@@ -10,10 +10,11 @@ from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.preloader import Preloader
from portprotonqt.system_overlay import SystemOverlay from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType from portprotonqt.input_manager import GamepadType
@@ -34,8 +35,10 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
@@ -47,14 +50,12 @@ from datetime import datetime
logger = get_logger(__name__) logger = get_logger(__name__)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Main window of PortProtonQt."""
games_loaded = Signal(list) games_loaded = Signal(list)
update_progress = Signal(int) # Signal to update progress bar update_progress = Signal(int)
update_status_message = Signal(str, int) # Signal to update status message update_status_message = Signal(str, int)
def __init__(self, app_name: str): def __init__(self, app_name: str):
super().__init__() super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.is_exiting = False self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
@@ -62,50 +63,50 @@ class MainWindow(QMainWindow):
self.theme = self.theme_manager.apply_theme(selected_theme) self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self._last_card_width = self.card_width
self.setWindowTitle(app_name) self.setWindowTitle(app_name)
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.games = [] self.games = []
self.filtered_games = self.games
self.game_processes = [] self.game_processes = []
self.target_exe = None self.target_exe = None
self.current_running_button = None self.current_running_button = None
self.portproton_location = get_portproton_location() self.portproton_location = get_portproton_location()
self.game_library_manager = GameLibraryManager(self, self.theme, None)
self.context_menu_manager = ContextMenuManager( self.context_menu_manager = ContextMenuManager(
self, self,
self.portproton_location, self.portproton_location,
self.theme, self.theme,
self.loadGames, self.loadGames,
self.updateGameGrid self.game_library_manager
) )
self.game_library_manager.context_menu_manager = self.context_menu_manager
QApplication.setStyle("Fusion") QApplication.setStyle("Fusion")
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE) self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.current_exec_line = None self.current_exec_line = None
self.currentDetailPage = None self.currentDetailPage = None
self.current_play_button = None self.current_play_button = None
self.current_focused_card = None self.current_focused_card: GameCard | None = None
self.current_hovered_card: GameCard | None = None
self.pending_games = [] self.pending_games = []
self.game_card_cache = {}
self.pending_images = {}
self.total_games = 0 self.total_games = 0
self.games_load_timer = QTimer(self) self.games_load_timer = QTimer(self)
self.games_load_timer.setSingleShot(True) self.games_load_timer.setSingleShot(True)
self.games_load_timer.timeout.connect(self.finalize_game_loading) self.games_load_timer.timeout.connect(self.finalize_game_loading)
self.games_loaded.connect(self.on_games_loaded) self.games_loaded.connect(self.on_games_loaded)
self.current_add_game_dialog = None self.current_add_game_dialog = None
self.current_hovered_card = None
# Добавляем таймер для дебаунсинга сохранения настроек
self.settingsDebounceTimer = QTimer(self) self.settingsDebounceTimer = QTimer(self)
self.settingsDebounceTimer.setSingleShot(True) self.settingsDebounceTimer.setSingleShot(True)
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка self.settingsDebounceTimer.setInterval(300)
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed) self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
read_time_config() read_time_config()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
self.legendary_config_path = os.path.join( self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache" "PortProtonQt", "legendary_cache"
@@ -144,7 +145,7 @@ class MainWindow(QMainWindow):
headerLayout.setContentsMargins(0, 0, 0, 0) headerLayout.setContentsMargins(0, 0, 0, 0)
headerLayout.addStretch() headerLayout.addStretch()
self.input_manager = InputManager(self) self.input_manager = InputManager(self) # type: ignore
self.input_manager.button_pressed.connect(self.updateControlHints) self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints) self.input_manager.dpad_moved.connect(self.updateControlHints)
@@ -196,15 +197,13 @@ class MainWindow(QMainWindow):
self.stackedWidget = QStackedWidget() self.stackedWidget = QStackedWidget()
mainLayout.addWidget(self.stackedWidget) mainLayout.addWidget(self.stackedWidget)
# Создаём все вкладки self.createInstalledTab()
self.createInstalledTab() # вкладка 0 self.createAutoInstallTab()
self.createAutoInstallTab() # вкладка 1 self.createEmulatorsTab()
self.createEmulatorsTab() # вкладка 2 self.createWineTab()
self.createWineTab() # вкладка 3 self.createPortProtonTab()
self.createPortProtonTab() # вкладка 4 self.createThemeTab()
self.createThemeTab() # вкладка 5
# Подсказки управления
self.controlHintsWidget = self.createControlHintsWidget() self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget) mainLayout.addWidget(self.controlHintsWidget)
@@ -221,6 +220,22 @@ class MainWindow(QMainWindow):
self.resize(width, height) self.resize(width, height)
else: else:
self.showNormal() self.showNormal()
self._preloader = Preloader(parent=self)
self._update_preloader_position()
def _update_preloader_position(self):
if self._preloader:
self._preloader.move(self.rect().center() - self._preloader.rect().center())
def _close_preloader(self):
if self._preloader:
self._preloader.close()
self._preloader = None
def on_slider_released(self) -> None:
"""Delegate to game library manager."""
if hasattr(self, 'game_library_manager'):
self.game_library_manager.on_slider_released()
def get_button_icon(self, action: str, gtype: GamepadType) -> str: def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type.""" """Get the icon name for a specific action and gamepad type."""
@@ -298,12 +313,12 @@ class MainWindow(QMainWindow):
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,): def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget() container = QWidget()
layout = QHBoxLayout(container) layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 5, 0, 0)
layout.setSpacing(6) layout.setSpacing(6)
# иконка кнопки # иконка кнопки
icon_label = QLabel() icon_label = QLabel()
icon_label.setFixedSize(32, 32) icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap() pixmap = QPixmap()
@@ -316,7 +331,7 @@ class MainWindow(QMainWindow):
if not pixmap.isNull(): if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled( icon_label.setPixmap(pixmap.scaled(
32, 32, 26, 26,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
)) ))
@@ -409,7 +424,7 @@ class MainWindow(QMainWindow):
pixmap.load(str(icon_path)) pixmap.load(str(icon_path))
if not pixmap.isNull(): if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled( icon_label.setPixmap(pixmap.scaled(
32, 32, 26, 26,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
)) ))
@@ -418,7 +433,7 @@ class MainWindow(QMainWindow):
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name) placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder: if placeholder:
pixmap.load(str(placeholder)) pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else: else:
container.setVisible(False) container.setVisible(False)
else: # Keyboard hint else: # Keyboard hint
@@ -429,32 +444,9 @@ class MainWindow(QMainWindow):
@Slot(list) @Slot(list)
def on_games_loaded(self, games: list[tuple]): def on_games_loaded(self, games: list[tuple]):
self.games = games self.game_library_manager.set_games(games)
favorites = read_favorites()
sort_method = read_sort_method()
# Sort by: favorites first, then descending playtime, then descending last launch
if sort_method == "playtime":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[11], -g[10]))
# Sort by: favorites first, then alphabetically by game name
elif sort_method == "alphabetical":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, g[0].lower()))
# Sort by: favorites first, then leave the rest in their original order
elif sort_method == "favorites":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1))
# Sort by: favorites first, then descending last launch, then descending playtime
elif sort_method == "last_launch":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
# Fallback: same as last_launch
else:
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
self.updateGameGrid()
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
self._close_preloader()
def open_portproton_forum_topic(self, topic_name: str): def open_portproton_forum_topic(self, topic_name: str):
"""Open the PortProton forum topic or search page for this game.""" """Open the PortProton forum topic or search page for this game."""
@@ -466,65 +458,6 @@ class MainWindow(QMainWindow):
url = QUrl(f"{base_url}t/{result}") url = QUrl(f"{base_url}t/{result}")
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Обработчик сигнала focusChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
# Если карточка получила фокус
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем текущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = None
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
self.current_focused_card = card
else:
# Если карточка потеряла фокус
if self.current_focused_card == card:
self.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Обработчик сигнала hoverChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
# Если мышь наведена на карточку
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем предыдущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = card
else:
# Если мышь покинула карточку
if self.current_hovered_card == card:
self.current_hovered_card = None
def loadGames(self): def loadGames(self):
display_filter = read_display_filter() display_filter = read_display_filter()
favorites = read_favorites() favorites = read_favorites()
@@ -797,7 +730,7 @@ class MainWindow(QMainWindow):
overlay = SystemOverlay(self, self.theme) overlay = SystemOverlay(self, self.theme)
overlay.exec() overlay.exec()
def createSearchWidget(self) -> tuple[QWidget, QLineEdit]: def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]:
self.container = QWidget() self.container = QWidget()
self.container.setStyleSheet(self.theme.CONTAINER_STYLE) self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
layout = QHBoxLayout(self.container) layout = QHBoxLayout(self.container)
@@ -823,88 +756,33 @@ class MainWindow(QMainWindow):
self.searchEdit.setClearButtonEnabled(True) self.searchEdit.setClearButtonEnabled(True)
self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
# Добавляем дебансирование для поиска
self.searchEdit.textChanged.connect(self.startSearchDebounce) self.searchEdit.textChanged.connect(self.startSearchDebounce)
self.searchDebounceTimer = QTimer(self) self.searchDebounceTimer = QTimer(self)
self.searchDebounceTimer.setSingleShot(True) self.searchDebounceTimer.setSingleShot(True)
self.searchDebounceTimer.setInterval(300) self.searchDebounceTimer.setInterval(300)
self.searchDebounceTimer.timeout.connect(self.filterGamesDelayed) self.searchDebounceTimer.timeout.connect(self.on_search_changed)
layout.addWidget(self.searchEdit) layout.addWidget(self.searchEdit)
return self.container, self.searchEdit return self.container, self.searchEdit
def on_search_text_changed(self, text: str):
"""Search text change handler with debounce."""
self.searchDebounceTimer.stop()
self.searchDebounceTimer.start()
@Slot()
def on_search_changed(self):
"""Triggers filtering with delay."""
if hasattr(self, 'game_library_manager'):
self.game_library_manager.filter_games_delayed()
def startSearchDebounce(self, text): def startSearchDebounce(self, text):
self.searchDebounceTimer.start() self.searchDebounceTimer.start()
def on_slider_released(self):
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.updateGameGrid()
def filterGamesDelayed(self):
"""Filters games based on search text and updates the grid."""
text = self.searchEdit.text().strip().lower()
if text == "":
self.filtered_games = self.games
else:
self.filtered_games = [game for game in self.games if text in game[0].lower()]
self.updateGameGrid(self.filtered_games)
def createInstalledTab(self): def createInstalledTab(self):
self.gamesLibraryWidget = QWidget() self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
searchWidget, self.searchEdit = self.createSearchWidget()
layout.addWidget(searchWidget)
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
# Слайдер
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
def calculate_card_width():
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Добавляем обработчик прокрутки для ленивой загрузки
scrollArea.verticalScrollBar().valueChanged.connect(self.loadVisibleImages)
self.stackedWidget.addWidget(self.gamesLibraryWidget) self.stackedWidget.addWidget(self.gamesLibraryWidget)
self.updateGameGrid() self.game_library_manager.update_game_grid()
def resizeEvent(self, event): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
@@ -921,136 +799,8 @@ class MainWindow(QMainWindow):
self._last_width = self.width() self._last_width = self.width()
if abs(self.width() - self._last_width) > 10: if abs(self.width() - self._last_width) > 10:
self._last_width = self.width() self._last_width = self.width()
self._update_preloader_position()
def loadVisibleImages(self):
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def updateGameGrid(self, games_list=None):
"""Обновляет сетку игровых карточек с сохранением порядка сортировки"""
# Подготовка данных
games_list = games_list if games_list is not None else self.games
search_text = self.searchEdit.text().strip().lower()
favorites = read_favorites()
sort_method = read_sort_method()
# Сортируем игры согласно текущим настройкам
def sort_key(game):
name = game[0]
# Избранные всегда первые
if name in favorites:
fav_order = 0
else:
fav_order = 1
if sort_method == "playtime":
return (fav_order, -game[11], -game[10]) # playtime_seconds, last_launch_ts
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else: # "last_launch" или по умолчанию
return (fav_order, -game[10], -game[11]) # last_launch_ts, playtime_seconds
sorted_games = sorted(games_list, key=sort_key)
# Создаем временный список для новых карточек
new_card_order = []
# Обрабатываем каждую игру в отсортированном порядке
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
# Если карточка уже существует - используем существующую
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
card.setVisible(should_be_visible)
new_card_order.append((game_key, card))
continue
# Создаем новую карточку
card = GameCard(
*game_data,
select_callback=self.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
# Подключаем сигналы
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
# Подключаем сигналы контекстного меню
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
# Добавляем в кэш и временный список
self.game_card_cache[game_key] = card
new_card_order.append((game_key, card))
card.setVisible(should_be_visible)
# Полностью перестраиваем макет в правильном порядке, чистим FlowLayout
while self.gamesListLayout.count():
child = self.gamesListLayout.takeAt(0)
if child.widget():
child.widget().setParent(None)
# Добавляем карточки в макет в отсортированном порядке
for _game_key, card in new_card_order:
self.gamesListLayout.addWidget(card)
# Загружаем обложку, если карточка видима
if card.isVisible():
self.loadVisibleImages()
# Удаляем карточки для игр, которых больше нет в списке
existing_keys = {game_key for game_key, _ in new_card_order}
for card_key in list(self.game_card_cache.keys()):
if card_key not in existing_keys:
card = self.game_card_cache.pop(card_key)
card.deleteLater()
if card_key in self.pending_images:
del self.pending_images[card_key]
# Принудительно обновляем макет
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.gamesListWidget.update()
# Сохраняем текущий размер карточек
self._last_card_width = self.card_width
def clearLayout(self, layout):
"""Удаляет все виджеты из layout."""
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
# Remove from game_card_cache if it's a GameCard
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
# Also remove from pending_images if present
if key in self.pending_images:
del self.pending_images[key]
widget.deleteLater()
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
@@ -1068,26 +818,22 @@ class MainWindow(QMainWindow):
break break
def openAddGameDialog(self, exe_path=None): def openAddGameDialog(self, exe_path=None):
"""Открывает диалоговое окно 'Add Game' с текущей темой."""
# Проверяем, открыт ли уже диалог
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible(): if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
self.current_add_game_dialog.activateWindow() # Активируем существующий диалог self.current_add_game_dialog.activateWindow()
self.current_add_game_dialog.raise_() # Поднимаем окно self.current_add_game_dialog.raise_()
return return
dialog = AddGameDialog(self, self.theme) dialog = AddGameDialog(self, self.theme)
dialog.setFocus(Qt.FocusReason.OtherFocusReason) dialog.setFocus(Qt.FocusReason.OtherFocusReason)
self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог self.current_add_game_dialog = dialog
# Предзаполняем путь к .exe при drag-and-drop
if exe_path: if exe_path:
dialog.exeEdit.setText(exe_path) dialog.exeEdit.setText(exe_path)
dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0]) dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0])
dialog.updatePreview() dialog.updatePreview()
# Обработчик закрытия диалога
def on_dialog_finished(): def on_dialog_finished():
self.current_add_game_dialog = None # Сбрасываем ссылку при закрытии self.current_add_game_dialog = None
dialog.finished.connect(on_dialog_finished) dialog.finished.connect(on_dialog_finished)
@@ -1099,33 +845,124 @@ class MainWindow(QMainWindow):
if not name or not exe_path: if not name or not exe_path:
return return
# Сохраняем .desktop файл
desktop_entry, desktop_path = dialog.getDesktopEntryData() desktop_entry, desktop_path = dialog.getDesktopEntryData()
if desktop_entry and desktop_path: if desktop_entry and desktop_path:
with open(desktop_path, "w", encoding="utf-8") as f: with open(desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry) f.write(desktop_entry)
os.chmod(desktop_path, 0o755) os.chmod(desktop_path, 0o755)
# Проверяем путь обложки, если он отличается от стандартной exe_name = os.path.splitext(os.path.basename(exe_path))[0]
if os.path.isfile(user_cover): xdg_data_home = os.getenv("XDG_DATA_HOME",
exe_name = os.path.splitext(os.path.basename(exe_path))[0] os.path.join(os.path.expanduser("~"), ".local", "share"))
xdg_data_home = os.getenv("XDG_DATA_HOME", custom_folder = os.path.join(
os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home,
custom_folder = os.path.join( "PortProtonQt",
xdg_data_home, "custom_data",
"PortProtonQt", exe_name
"custom_data", )
exe_name os.makedirs(custom_folder, exist_ok=True)
)
os.makedirs(custom_folder, exist_ok=True)
# Сохраняем пользовательскую обложку как cover.* # Handle user cover copy
cover_path = None
if user_cover:
ext = os.path.splitext(user_cover)[1].lower() ext = os.path.splitext(user_cover)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]: if os.path.isfile(user_cover) and ext in [".png", ".jpg", ".jpeg", ".bmp"]:
shutil.copyfile(user_cover, os.path.join(custom_folder, f"cover{ext}")) copied_cover = os.path.join(custom_folder, f"cover{ext}")
shutil.copyfile(user_cover, copied_cover)
cover_path = copied_cover
self.games = self.loadGames() # Parse .desktop (adapt from _process_desktop_file_async)
self.updateGameGrid() entry = parse_desktop_entry(desktop_path)
if not entry:
return
description = entry.get("Comment", "")
exec_line = entry.get("Exec", exe_path)
# Builtin custom folder (adapt path)
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data")
builtin_game_folder = os.path.join(builtin_custom_folder, exe_name)
builtin_cover = ""
if os.path.exists(builtin_game_folder):
builtin_files = set(os.listdir(builtin_game_folder))
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in builtin_files:
builtin_cover = os.path.join(builtin_game_folder, candidate)
break
# User cover fallback
user_cover_path = cover_path # Already set if user provided
# Statistics (playtime, last launch - defaults for new)
playtime_seconds = 0
formatted_playtime = format_playtime(playtime_seconds)
last_played_timestamp = 0
last_launch = _("Never")
# Language for translations
language_code = get_egs_language()
# Read translations from metadata.txt
user_metadata_file = os.path.join(custom_folder, "metadata.txt")
builtin_metadata_file = os.path.join(builtin_game_folder, "metadata.txt")
translations = {'name': name, 'description': description}
if os.path.exists(user_metadata_file):
translations = read_metadata_translations(user_metadata_file, language_code)
elif os.path.exists(builtin_metadata_file):
translations = read_metadata_translations(builtin_metadata_file, language_code)
final_name = translations['name']
final_desc = translations['description']
def on_steam_info(steam_info: dict):
nonlocal final_name, final_desc
# Adapt final_cover logic from _process_desktop_file_async
final_cover = (user_cover_path if user_cover_path else
builtin_cover if builtin_cover else
steam_info.get("cover", "") or entry.get("Icon", ""))
# Use Steam description as fallback if no translation
steam_desc = steam_info.get("description", "")
if steam_desc and steam_desc != final_desc:
final_desc = steam_desc
# Use Steam name as fallback if better
steam_name = steam_info.get("name", "")
if steam_name and steam_name != final_name:
final_name = steam_name
# Build full game_data tuple with all Steam data
game_data = (
final_name,
final_desc,
final_cover,
steam_info.get("appid", ""),
exec_line,
steam_info.get("controller_support", ""),
last_launch,
formatted_playtime,
steam_info.get("protondb_tier", ""),
steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton"
)
# Incremental add
self.game_library_manager.add_game_incremental(game_data)
# Status message
msg = _("Added '{name}'").format(name=final_name)
self.statusBar().showMessage(msg, 3000)
# Trigger visible images load
QTimer.singleShot(200, self.game_library_manager.load_visible_images)
self.update_status_message.emit(_("Enriching from Steam..."), 3000)
from portprotonqt.steam_api import get_steam_game_info_async
get_steam_game_info_async(final_name, exec_line, on_steam_info)
def createAutoInstallTab(self): def createAutoInstallTab(self):
"""Вкладка 'Auto Install'.""" """Вкладка 'Auto Install'."""
@@ -1487,18 +1324,14 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(_("Cache cleared"), 3000) self.statusBar().showMessage(_("Cache cleared"), 3000)
def applySettingsDelayed(self): def applySettingsDelayed(self):
"""Applies settings with the new filter and updates the game list."""
read_time_config() read_time_config()
self.games = [] self.games = []
self.loadGames() self.loadGames()
display_filter = read_display_filter() display_filter = read_display_filter()
for card in self.game_card_cache.values(): for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(display_filter) card.update_badge_visibility(display_filter)
def savePortProtonSettings(self): def savePortProtonSettings(self):
"""
Сохраняет параметры конфигурации в конфигурационный файл.
"""
time_idx = self.timeDetailCombo.currentIndex() time_idx = self.timeDetailCombo.currentIndex()
time_key = self.time_keys[time_idx] time_key = self.time_keys[time_idx]
save_time_config(time_key) save_time_config(time_key)
@@ -1511,7 +1344,6 @@ class MainWindow(QMainWindow):
filter_key = self.filter_keys[filter_idx] filter_key = self.filter_keys[filter_idx]
save_display_filter(filter_key) save_display_filter(filter_key)
# Сохранение proxy настроек
proxy_url = self.proxyUrlEdit.text().strip() proxy_url = self.proxyUrlEdit.text().strip()
proxy_user = self.proxyUserEdit.text().strip() proxy_user = self.proxyUserEdit.text().strip()
proxy_password = self.proxyPasswordEdit.text().strip() proxy_password = self.proxyPasswordEdit.text().strip()
@@ -1523,11 +1355,10 @@ class MainWindow(QMainWindow):
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked() auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad) save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
# Сохранение настройки виброотдачи геймпада
rumble_enabled = self.gamepadRumbleCheckBox.isChecked() rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
save_rumble_config(rumble_enabled) save_rumble_config(rumble_enabled)
for card in self.game_card_cache.values(): for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(filter_key) card.update_badge_visibility(filter_key)
if self.currentDetailPage and self.current_exec_line: if self.currentDetailPage and self.current_exec_line:
@@ -1540,14 +1371,12 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start() self.settingsDebounceTimer.start()
# Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected): if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
self.showFullScreen() self.showFullScreen()
else: else:
# Если обе галочки сняты и геймпад не подключен, возвращаем нормальное состояние
self.showNormal() self.showNormal()
self.resize(*read_window_geometry()) # Восстанавливаем сохраненные размеры окна self.resize(*read_window_geometry())
self.statusBar().showMessage(_("Settings saved"), 3000) self.statusBar().showMessage(_("Settings saved"), 3000)
@@ -2129,7 +1958,7 @@ class MainWindow(QMainWindow):
favorites.append(game_name) favorites.append(game_name)
label.setText("") label.setText("")
save_favorites(favorites) save_favorites(favorites)
self.updateGameGrid() self.game_library_manager.update_game_grid()
def activateFocusedWidget(self): def activateFocusedWidget(self):
"""Activate the currently focused widget.""" """Activate the currently focused widget."""

50
portprotonqt/preloader.py Normal file
View File

@@ -0,0 +1,50 @@
import time
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
from PySide6.QtWidgets import QWidget
class Preloader(QWidget):
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
super().__init__(parent)
self.setFixedSize(150, 150)
self._speed = speed
self._line_width = line_line_width
self._color1 = color
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
self._start_time = time.time()
def showEvent(self, event):
self._start_time = time.time()
def paintEvent(self, event):
rect = self._get_preloader_rect()
center = rect.center()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(self._get_pen())
painter.translate(center)
painter.rotate(self._get_angle())
painter.translate(-center)
painter.drawArc(rect, 0, 270 * 16)
self.update()
def _get_pen(self) -> QPen:
gradient = QConicalGradient()
gradient.setCenter(self.rect().center())
gradient.setColorAt(0, self._color1)
gradient.setColorAt(1, self._color2)
pen = QPen(QBrush(gradient), self._line_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _get_angle(self) -> float:
duration = time.time() - self._start_time
return (self._speed * duration) % 360.0
def _get_preloader_rect(self) -> QRect:
size = self._line_width // 2
rect = self.rect()
rect.adjust(size, size, -size, -size)
return rect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
version="1.1"
viewBox="0 0 48 48"
xml:space="preserve"
id="svg2"
sodipodi:docname="key_context.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="8.6915209"
inkscape:cx="72.311855"
inkscape:cy="22.780823"
inkscape:window-width="2560"
inkscape:window-height="1406"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
id="path10"
sodipodi:nodetypes="csccscc" /><path
fill="#000000"
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
id="path2"
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
id="g15"
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
id="path14" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
id="path15" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg>

After

Width:  |  Height:  |  Size: 605 B