diff --git a/Makefile b/Makefile index 9218056a5..2b0fc3272 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ mypy: # So give pyright a try. PYRIGHT_PKGS= PYRIGHT_PKGS+=feeluown/gui/uimain/sidebar.py +PYRIGHT_PKGS+=feeluown/gui/uimain/provider_bar.py pyright: pyright ${PYRIGHT_PKGS} diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index 6b38b345b..be2cc9ca1 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -8,7 +8,7 @@ ) from PyQt5.QtWidgets import QWidget -from feeluown.gui.helpers import random_solarized_color, painter_save +from feeluown.gui.helpers import random_solarized_color, painter_save, IS_MACOS class SizedPixmapDrawer: @@ -468,3 +468,26 @@ def paint(self, painter): # Draw the fire shape painter.drawPath(path) + + +class EmojiIconDrawer: + def __init__(self, emoji: str, length: int, padding: int): + self._emoji = emoji + self._length = length + self._padding = padding + + self._emoji_width = self._length - 2 * self._padding + + def paint(self, painter: QPainter): + width = self._emoji_width + with painter_save(painter): + painter.translate(self._padding, self._padding) + font = painter.font() + if IS_MACOS: + # -4 works well on macOS when length is in range(30, 200) + font.setPixelSize(width - 4) + else: + # -1 works well on KDE when length is in range(30, 200) + font.setPixelSize(width - (self._length//20)) + painter.setFont(font) + painter.drawText(0, 0, width, width, Qt.AlignHCenter | Qt.AlignVCenter, self._emoji) diff --git a/feeluown/gui/provider_ui.py b/feeluown/gui/provider_ui.py index 7d7191555..e6714dd90 100644 --- a/feeluown/gui/provider_ui.py +++ b/feeluown/gui/provider_ui.py @@ -1,5 +1,8 @@ from abc import abstractmethod, ABC -from typing import TYPE_CHECKING, runtime_checkable, Protocol, Dict, Optional, List +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, runtime_checkable, Protocol, Dict, Optional, List, Callable, Any, +) from PyQt5.QtCore import pyqtSignal, QObject @@ -11,6 +14,13 @@ from feeluown.app.gui_app import GuiApp +@dataclass +class NavBtn: + icon: str + text: str + cb: Callable[[], Any] + + @runtime_checkable class UISupportsLoginOrGoHome(Protocol): @@ -44,6 +54,28 @@ def discovery(self): ... +@runtime_checkable +class UISupportsNavBtns(Protocol): + """ + Provider UI can add its own navigation buttons to the sidebar. + """ + + @abstractmethod + def list_nav_btns(self) -> List[NavBtn]: + ... + + +@runtime_checkable +class UISupportsCreatePlaylist(Protocol): + """ + Provider UI can create playlist. + """ + + @abstractmethod + def create_playlist(self): + ... + + class AbstractProviderUi(ABC): """Abstract base class for provider ui.""" diff --git a/feeluown/gui/uimain/provider_bar.py b/feeluown/gui/uimain/provider_bar.py new file mode 100644 index 000000000..d1d129cff --- /dev/null +++ b/feeluown/gui/uimain/provider_bar.py @@ -0,0 +1,229 @@ +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QFrame, QLabel, QVBoxLayout, QHBoxLayout, QFormLayout, + QDialog, QLineEdit, QDialogButtonBox, QMessageBox, QWidget, QSizePolicy +) + +from feeluown.excs import ProviderIOError, NoUserLoggedIn +from feeluown.library import ( + SupportsPlaylistDelete, SupportsPlaylistCreateByName, SupportsCurrentUser, +) +from feeluown.utils import aio +from feeluown.gui.provider_ui import UISupportsNavBtns, UISupportsCreatePlaylist +from feeluown.gui.components import Avatar +from feeluown.gui.widgets import ( + DiscoveryButton, StarButton, PlusButton, TriagleButton, + EmojiButton, +) +from feeluown.gui.widgets.playlists import PlaylistsView +from feeluown.gui.widgets.my_music import MyMusicView + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp + + +class ListViewContainer(QFrame): + + def __init__(self, label, view, parent=None): + super().__init__(parent) + + self._btn_length = 14 + self._label = label + self._view = view + self._toggle_btn = TriagleButton(length=self._btn_length, padding=0.2) + self.create_btn = PlusButton(length=self._btn_length) + # Show this button when needed. + self.create_btn.hide() + + self._toggle_btn.clicked.connect(self.toggle_view) + self.setup_ui() + + def setup_ui(self): + self._label.setFixedHeight(25) + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(0) + + self._t_h_layout = QHBoxLayout() + self._b_h_layout = QHBoxLayout() + self._t_h_layout.addWidget(self._label) + self._t_h_layout.addStretch(0) + self._t_h_layout.addWidget(self.create_btn) + self._t_h_layout.addSpacing(self._btn_length // 2) + self._t_h_layout.addWidget(self._toggle_btn) + self._b_h_layout.addWidget(self._view) + + self._layout.addLayout(self._t_h_layout) + self._layout.addLayout(self._b_h_layout) + # XXX: 本意是让 ListViewContainer 下方不要出现多余的空间 + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + + def toggle_view(self): + if self._view.isVisible(): + self._toggle_btn.set_direction('down') + self._view.hide() + else: + self._toggle_btn.set_direction('up') + self._view.show() + + +LVC = ListViewContainer + + +class ProviderBar(QWidget): + """ + A panel shows provider-specific contents. + """ + def __init__(self, app: 'GuiApp', parent=None): + super().__init__(parent) + self._app = app + + self.discovery_btn = DiscoveryButton(height=30, padding=0.2, parent=self) + self.fav_btn = StarButton('我的收藏', height=30, parent=self) + self.fold_top_btn = TriagleButton(length=14, padding=0.2) + self.fold_top_btn.setCheckable(True) + + self.playlists_header = QLabel('歌单列表', self) + self.my_music_header = QLabel('我的音乐', self) + + self._layout = QVBoxLayout(self) + # Layout to let provider add it's own buttons. + self._btn_layout = QVBoxLayout() + self.playlists_view = PlaylistsView(self) + self.my_music_view = MyMusicView(self) + self.playlists_view.setModel(self._app.pl_uimgr.model) + self.my_music_view.setModel(self._app.mymusic_uimgr.model) + + self.playlists_con = LVC(self.playlists_header, self.playlists_view) + self.my_music_con = LVC(self.my_music_header, self.my_music_view) + + self.playlists_view.show_playlist.connect( + lambda pl: self._app.browser.goto(model=pl)) + self.playlists_view.remove_playlist.connect(self._remove_playlist) + self.playlists_con.create_btn.clicked.connect(self._create_playlist) + self._app.current_pvd_ui_mgr.changed.connect( + self.on_current_pvd_ui_changed) + self.discovery_btn.clicked.connect( + lambda: self._app.browser.goto(page='/rec')) + self.fav_btn.clicked.connect( + lambda: self._app.browser.goto(page='/my_fav')) + + self.setup_ui() + + def setup_ui(self): + self._layout.setSpacing(0) + self._layout.setContentsMargins(0, 0, 0, 0) + + self.playlists_view.setFrameShape(QFrame.NoFrame) + self.my_music_view.setFrameShape(QFrame.NoFrame) + + self._avatar_layout = QHBoxLayout() + self._avatar_layout.addWidget(Avatar(self._app, height=48)) + self._avatar_layout.addWidget(self.fold_top_btn) + + self._layout.addLayout(self._avatar_layout) + self._layout.addWidget(self.discovery_btn) + self._layout.addWidget(self.fav_btn) + self._layout.addLayout(self._btn_layout) + self._layout.addWidget(self.my_music_con) + self._layout.addWidget(self.playlists_con) + + # 让各个音乐库来决定是否显示这些组件 + self.playlists_con.hide() + self.my_music_con.hide() + self.discovery_btn.setDisabled(True) + self.fav_btn.setDisabled(True) + self.discovery_btn.setToolTip('当前资源提供方未知') + self.fold_top_btn.setToolTip('折叠/打开“主页和本地收藏集”功能') + + def on_current_pvd_ui_changed(self, pvd_ui, _): + self._clear_btns() + if pvd_ui: + self.discovery_btn.setEnabled(True) + self.fav_btn.setEnabled(True) + self.discovery_btn.setToolTip(f'点击进入 {pvd_ui.provider.name} 推荐页') + if isinstance(pvd_ui, UISupportsNavBtns): + for btn in pvd_ui.list_nav_btns(): + qt_btn = EmojiButton(btn.icon, btn.text, height=30, parent=self) + qt_btn.clicked.connect(btn.cb) + self._add_btn(qt_btn) + + if isinstance(pvd_ui, UISupportsCreatePlaylist): + self.playlists_con.create_btn.show() + self.playlists_con.create_btn.clicked.connect(pvd_ui.create_playlist) + else: + self.playlists_con.create_btn.hide() + else: + self.discovery_btn.setEnabled(False) + self.fav_btn.setEnabled(False) + + def _add_btn(self, btn): + self._btn_layout.addWidget(btn) + + def _clear_btns(self): + for btn in self._btn_layout.children(): + btn.deleteLater() + + def _create_playlist(self): + provider_ui = self._app.current_pvd_ui_mgr.get() + if provider_ui is None: + self._app.show_msg('当前的资源提供方未注册其 UI') + return + provider = provider_ui.provider + if not isinstance(provider, SupportsPlaylistCreateByName) \ + or not isinstance(provider, SupportsCurrentUser) \ + or not provider.has_current_user(): + self._app.show_msg('当前的资源提供方不支持创建歌单') + return + + dialog = QDialog(self) + # Set WA_DeleteOnClose so that the dialog can be deleted (from self.children). + dialog.setAttribute(Qt.WA_DeleteOnClose) + layout = QFormLayout(dialog) + title_edit = QLineEdit(dialog) + layout.addRow('歌单名', title_edit) + button_box = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Yes) + layout.addRow('', button_box) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + + def create_playlist_and_reload(): + title = title_edit.text() + + async def do(): + try: + playlist = await aio.run_fn(provider.playlist_create_by_name, title) + except (ProviderIOError, NoUserLoggedIn) as e: + QMessageBox.warning(self._app, '错误', f"创建歌单 '{title}' 失败: {e}") + else: + # Add playlist to pl_uimgr is a workaround, which may cause bug. + # For example, the order of the newly created playlist should be + # in the top for some providers. + # TODO: re-fetch user's playlists and fill the UI. + self._app.pl_uimgr.add(playlist, is_fav=False) + self._app.show_msg(f"创建歌单 '{title}' 成功") + + aio.run_afn(do) + + dialog.accepted.connect(create_playlist_and_reload) + dialog.open() + + def _remove_playlist(self, playlist): + + async def do(): + provider = self._app.library.get(playlist.source) + if isinstance(provider, SupportsPlaylistDelete): + ok = await aio.run_fn(provider.playlist_delete, playlist.identifier) + self._app.show_msg(f"删除歌单 {playlist.name} {'成功' if ok else '失败'}") + if ok is True: + self._app.pl_uimgr.model.remove(playlist) + else: + self._app.show_msg(f'资源提供方({playlist.source})不支持删除歌单') + + box = QMessageBox(QMessageBox.Warning, '提示', f"确认删除歌单 '{playlist.name}' 吗?", + QMessageBox.Yes | QMessageBox.No, self) + box.accepted.connect(lambda: aio.run_afn(do)) + box.open() diff --git a/feeluown/gui/uimain/sidebar.py b/feeluown/gui/uimain/sidebar.py index b113a61df..e8e5e3a69 100644 --- a/feeluown/gui/uimain/sidebar.py +++ b/feeluown/gui/uimain/sidebar.py @@ -2,82 +2,23 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import QSize, Qt -from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout, QSizePolicy, QScrollArea, \ - QHBoxLayout, QFormLayout, QDialog, QLineEdit, QDialogButtonBox, QMessageBox, \ - QWidget - -from feeluown.excs import ProviderIOError, NoUserLoggedIn -from feeluown.library import ( - SupportsPlaylistDelete, - SupportsPlaylistCreateByName, - SupportsCurrentUser, +from PyQt5.QtWidgets import ( + QFrame, QLabel, QVBoxLayout, QScrollArea, QMessageBox, + QFormLayout, QDialog, QLineEdit, QDialogButtonBox, ) + from feeluown.collection import CollectionAlreadyExists, CollectionType -from feeluown.utils import aio from feeluown.utils.reader import create_reader, Reader from feeluown.utils.aio import run_fn -from feeluown.gui.components import Avatar, CollectionListView -from feeluown.gui.widgets import ( - DiscoveryButton, - HomeButton, - PlusButton, - TriagleButton, - StarButton, -) +from feeluown.gui.components import CollectionListView +from feeluown.gui.widgets import HomeButton from feeluown.gui.widgets.separator import Separator -from feeluown.gui.widgets.playlists import PlaylistsView -from feeluown.gui.widgets.my_music import MyMusicView +from .provider_bar import ProviderBar, ListViewContainer as LVC if TYPE_CHECKING: from feeluown.app.gui_app import GuiApp -class ListViewContainer(QFrame): - - def __init__(self, label, view, parent=None): - super().__init__(parent) - - self._btn_length = 14 - self._label = label - self._view = view - self._toggle_btn = TriagleButton(length=self._btn_length, padding=0.2) - self.create_btn = PlusButton(length=self._btn_length) - # Show this button when needed. - self.create_btn.hide() - - self._toggle_btn.clicked.connect(self.toggle_view) - self.setup_ui() - - def setup_ui(self): - self._label.setFixedHeight(25) - - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(0) - - self._t_h_layout = QHBoxLayout() - self._b_h_layout = QHBoxLayout() - self._t_h_layout.addWidget(self._label) - self._t_h_layout.addStretch(0) - self._t_h_layout.addWidget(self.create_btn) - self._t_h_layout.addSpacing(self._btn_length // 2) - self._t_h_layout.addWidget(self._toggle_btn) - self._b_h_layout.addWidget(self._view) - - self._layout.addLayout(self._t_h_layout) - self._layout.addLayout(self._b_h_layout) - # XXX: 本意是让 ListViewContainer 下方不要出现多余的空间 - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) - - def toggle_view(self): - if self._view.isVisible(): - self._toggle_btn.set_direction('down') - self._view.hide() - else: - self._toggle_btn.set_direction('up') - self._view.show() - - class LeftPanel(QScrollArea): def __init__(self, app: 'GuiApp', parent=None): @@ -115,143 +56,6 @@ async def show_provider_current_user_playlists(self, provider): self._app.pl_uimgr.add(fav_playlists, is_fav=True) -LVC = ListViewContainer - - -class ProviderPanel(QWidget): - """ - A panel shows provider-specific contents. - """ - def __init__(self, app: 'GuiApp', parent=None): - super().__init__(parent) - self._app = app - - self.discovery_btn = DiscoveryButton(height=30, padding=0.2, parent=self) - self.fav_btn = StarButton('我的收藏', height=30, parent=self) - self.fold_top_btn = TriagleButton(length=14, padding=0.2) - self.fold_top_btn.setCheckable(True) - - self.playlists_header = QLabel('歌单列表', self) - self.my_music_header = QLabel('我的音乐', self) - - self._layout = QVBoxLayout(self) - self.playlists_view = PlaylistsView(self) - self.my_music_view = MyMusicView(self) - self.playlists_view.setModel(self._app.pl_uimgr.model) - self.my_music_view.setModel(self._app.mymusic_uimgr.model) - - self.playlists_con = LVC(self.playlists_header, self.playlists_view) - self.my_music_con = LVC(self.my_music_header, self.my_music_view) - - self.playlists_view.show_playlist.connect( - lambda pl: self._app.browser.goto(model=pl)) - self.playlists_view.remove_playlist.connect(self._remove_playlist) - self.playlists_con.create_btn.clicked.connect(self._create_playlist) - self._app.current_pvd_ui_mgr.changed.connect( - self.on_current_pvd_ui_changed) - self.discovery_btn.clicked.connect( - lambda: self._app.browser.goto(page='/rec')) - self.fav_btn.clicked.connect( - lambda: self._app.browser.goto(page='/my_fav')) - - self.setup_ui() - - def setup_ui(self): - self._layout.setSpacing(0) - self._layout.setContentsMargins(0, 0, 0, 0) - - self.playlists_view.setFrameShape(QFrame.NoFrame) - self.my_music_view.setFrameShape(QFrame.NoFrame) - - self._avatar_layout = QHBoxLayout() - self._avatar_layout.addWidget(Avatar(self._app, height=48)) - self._avatar_layout.addWidget(self.fold_top_btn) - - self._layout.addLayout(self._avatar_layout) - self._layout.addWidget(self.discovery_btn) - self._layout.addWidget(self.fav_btn) - self._layout.addWidget(self.my_music_con) - self._layout.addWidget(self.playlists_con) - - # 让各个音乐库来决定是否显示这些组件 - self.playlists_con.hide() - self.my_music_con.hide() - self.discovery_btn.setDisabled(True) - self.fav_btn.setDisabled(True) - self.discovery_btn.setToolTip('当前资源提供方未知') - self.fold_top_btn.setToolTip('折叠/打开“主页和本地收藏集”功能') - - def on_current_pvd_ui_changed(self, pvd_ui, _): - if pvd_ui: - self.discovery_btn.setEnabled(True) - self.fav_btn.setEnabled(True) - self.discovery_btn.setToolTip(f'点击进入 {pvd_ui.provider.name} 推荐页') - else: - self.discovery_btn.setEnabled(False) - self.fav_btn.setEnabled(False) - - def _create_playlist(self): - provider_ui = self._app.current_pvd_ui_mgr.get() - if provider_ui is None: - self._app.show_msg('当前的资源提供方未注册其 UI') - return - provider = provider_ui.provider - if not isinstance(provider, SupportsPlaylistCreateByName) \ - or not isinstance(provider, SupportsCurrentUser) \ - or not provider.has_current_user(): - self._app.show_msg('当前的资源提供方不支持创建歌单') - return - - dialog = QDialog(self) - # Set WA_DeleteOnClose so that the dialog can be deleted (from self.children). - dialog.setAttribute(Qt.WA_DeleteOnClose) - layout = QFormLayout(dialog) - title_edit = QLineEdit(dialog) - layout.addRow('歌单名', title_edit) - button_box = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Yes) - layout.addRow('', button_box) - button_box.accepted.connect(dialog.accept) - button_box.rejected.connect(dialog.reject) - - def create_playlist_and_reload(): - title = title_edit.text() - - async def do(): - try: - playlist = await aio.run_fn(provider.playlist_create_by_name, title) - except (ProviderIOError, NoUserLoggedIn) as e: - QMessageBox.warning(self._app, '错误', f"创建歌单 '{title}' 失败: {e}") - else: - # Add playlist to pl_uimgr is a workaround, which may cause bug. - # For example, the order of the newly created playlist should be - # in the top for some providers. - # TODO: re-fetch user's playlists and fill the UI. - self._app.pl_uimgr.add(playlist, is_fav=False) - self._app.show_msg(f"创建歌单 '{title}' 成功") - - aio.run_afn(do) - - dialog.accepted.connect(create_playlist_and_reload) - dialog.open() - - def _remove_playlist(self, playlist): - - async def do(): - provider = self._app.library.get(playlist.source) - if isinstance(provider, SupportsPlaylistDelete): - ok = await aio.run_fn(provider.playlist_delete, playlist.identifier) - self._app.show_msg(f"删除歌单 {playlist.name} {'成功' if ok else '失败'}") - if ok is True: - self._app.pl_uimgr.model.remove(playlist) - else: - self._app.show_msg(f'资源提供方({playlist.source})不支持删除歌单') - - box = QMessageBox(QMessageBox.Warning, '提示', f"确认删除歌单 '{playlist.name}' 吗?", - QMessageBox.Yes | QMessageBox.No, self) - box.accepted.connect(lambda: aio.run_afn(do)) - box.open() - - class _LeftPanel(QFrame): def __init__(self, app: 'GuiApp', parent=None): @@ -266,10 +70,9 @@ def __init__(self, app: 'GuiApp', parent=None): '新建 fuo 文件,则可以新建收藏集,文件名即是收藏集的名字。\n\n' '手动编辑 fuo 文件即可编辑收藏集中的音乐资源,也可以在界面上拖拽来增删歌曲。') self.collections_view = CollectionListView(self._app) - self.collections_con = ListViewContainer(self.collections_header, - self.collections_view) + self.collections_con = LVC(self.collections_header, self.collections_view) self._top_separator = Separator(self._app) - self.provider_bar = ProviderPanel(self._app) + self.provider_bar = ProviderBar(self._app) # For backward compatibility. self.playlists_con = self.provider_bar.playlists_con diff --git a/feeluown/gui/widgets/__init__.py b/feeluown/gui/widgets/__init__.py index 3dea0451f..3e31f0963 100644 --- a/feeluown/gui/widgets/__init__.py +++ b/feeluown/gui/widgets/__init__.py @@ -6,5 +6,5 @@ PlusButton, TriagleButton, DiscoveryButton, SelfPaintAbstractIconTextButton, CalendarButton, RankButton, StarButton, PlayPauseButton, PlayNextButton, PlayPreviousButton, - MVButton, VolumeButton, HotButton, + MVButton, VolumeButton, HotButton, EmojiButton ) diff --git a/feeluown/gui/widgets/selfpaint_btn.py b/feeluown/gui/widgets/selfpaint_btn.py index e8db69c8f..5cb248644 100644 --- a/feeluown/gui/widgets/selfpaint_btn.py +++ b/feeluown/gui/widgets/selfpaint_btn.py @@ -12,6 +12,7 @@ VolumeIconDrawer, SearchIconDrawer, FireIconDrawer, + EmojiIconDrawer, ) from feeluown.gui.helpers import darker_or_lighter, painter_save @@ -353,6 +354,15 @@ def draw_icon(self, painter): self.hot_icon.paint(painter) +class EmojiButton(SelfPaintAbstractIconTextButton): + def __init__(self, emoji: str, text='表情', *args, **kwargs): + super().__init__(text, *args, **kwargs) + self.emoji_icon = EmojiIconDrawer(emoji, self.height(), self._padding) + + def draw_icon(self, painter): + self.emoji_icon.paint(painter) + + class PlayButton(SelfPaintAbstractSquareButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -536,8 +546,10 @@ def paintEvent(self, _): with simple_layout(QVBoxLayout) as layout: l1 = QHBoxLayout() l2 = QHBoxLayout() + l3 = QVBoxLayout() layout.addLayout(l1) layout.addLayout(l2) + layout.addLayout(l3) l1.addWidget(LeftArrowButton(length=length)) right = RightArrowButton(length=length) @@ -546,19 +558,23 @@ def paintEvent(self, _): l1.addWidget(SearchSwitchButton(length=length)) l1.addWidget(SettingsButton(length=length)) l1.addWidget(RecentlyPlayedButton(height=length)) - l1.addWidget(HomeButton(height=length)) - l1.addWidget(DiscoveryButton(height=length)) l1.addWidget(TriagleButton(length=length, direction='up')) l1.addWidget(CalendarButton(height=length)) - l1.addWidget(RankButton(height=length)) - l1.addWidget(StarButton(height=length)) l2.addWidget(PlayPreviousButton(length=length)) - l2.addWidget(PlayPauseButton(length=100)) + l2.addWidget(PlayPauseButton(length=60)) l2.addWidget(PlayNextButton(length=length)) volume_button = VolumeButton(length=length) volume_button.set_volume(60) l2.addWidget(volume_button) - l2.addWidget(HotButton(height=100)) l2.addStretch(0) + + l3.addWidget(HotButton(height=length)) + l3.addWidget(HomeButton(height=length)) + l3.addWidget(DiscoveryButton(height=length)) + l3.addWidget(RankButton(height=length)) + l3.addWidget(StarButton(height=length)) + l3.addWidget(EmojiButton('😁', '开心', height=length)) + l3.addWidget(EmojiButton('🔥', '热门', height=length)) + l3.addStretch(0)