Skip to content

Commit cfe565b

Browse files
authored
gui: polish discovery page layout and align recommendation panel UX (#1002)
1 parent 023e8c1 commit cfe565b

File tree

12 files changed

+499
-202
lines changed

12 files changed

+499
-202
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ feeluown.egg-info/
7878
*.dat
7979
htmlcov/
8080
.aider*
81+
.tasks/
8182

8283
# ignore *.dll
8384
*.dll

AGENTS.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# AGENTS.md
2+
3+
This file is a working draft for contributors/agents. It summarizes project
4+
structure, core docs, and practical workflows for changes in FeelUOwn.
5+
6+
## 1) Project Overview
7+
8+
- Project: FeelUOwn (desktop music player, Python + Qt)
9+
- Main package: `feeluown/`
10+
- Entrypoint CLI: `feeluown` / `fuo`
11+
- App modes: GUI-first, with protocol and server capabilities
12+
13+
Key references:
14+
- `README.md`
15+
- `README.en.md`
16+
- `docs/source/index.rst`
17+
18+
## 2) Repository Map (high signal)
19+
20+
- `feeluown/`: application source
21+
- `app/`: app bootstrap, config, lifecycle
22+
- `entry_points/`: CLI entry logic (`run.py`)
23+
- `gui/`: Qt UI (pages, widgets, sidebars, provider UI)
24+
- `library/`: provider abstraction and protocols
25+
- `player/`: playback, playlist, FM/radio, media handling
26+
- `server/`, `webserver/`: protocol/server side logic
27+
- `models/`, `serializers/`, `utils/`: common infra
28+
- `tests/`: unit tests
29+
- `integration-tests/`: integration test runner
30+
- `docs/source/`: user + developer docs
31+
- `Makefile`: lint/test/build tasks
32+
- `pyproject.toml` and `uv.lock`: dependency/runtime config
33+
34+
## 3) Development Workflow (uv-first)
35+
36+
Use `uv` as the default project/dependency runner. Prefer `uv` commands over
37+
direct `pip` usage for daily development.
38+
39+
Suggested setup:
40+
1. Prepare a Python >= 3.10 environment
41+
2. Sync dependencies with `uv`
42+
3. Run checks via `uv run`
43+
44+
Useful commands:
45+
- `uv sync --group dev --extra qt --extra jsonrpc --extra battery`
46+
- `uv run make pytest`
47+
- `uv run make test`
48+
- `uv run make integration_test`
49+
- `uv run make lint`
50+
51+
Notes:
52+
- GUI tests are partially excluded in default pytest addopts.
53+
- Integration tests run with `QT_QPA_PLATFORM=offscreen`.
54+
- Before commit/push for PR updates, run full `uv run make test` and record
55+
result summary in the PR thread.
56+
57+
## 4) Coding and Contribution Style
58+
59+
Primary docs:
60+
- `docs/source/dev_quickstart.rst`
61+
- `docs/source/coding_style.rst`
62+
- `docs/source/contributing.rst`
63+
- `docs/source/arch.rst`
64+
65+
Practical conventions:
66+
- Prefer small, focused changes.
67+
- Add/adjust tests for behavior changes under `feeluown/`.
68+
- Keep comments/docstrings in English.
69+
- For Qt widgets, prefer a `setup_ui` style split when code grows.
70+
- Handle provider/network exceptions defensively in GUI flows.
71+
72+
## 5) GUI Architecture Rules
73+
74+
Layering rules for GUI code:
75+
- `gui/widgets/`: app-independent reusable widgets.
76+
- `gui/components/`: reusable UI units that depend on `app` or app managers.
77+
- `gui/pages/`: route-level orchestration and page composition only.
78+
79+
Placement rule for shared UI:
80+
- If a shared UI piece needs `app` (e.g. browser navigation, provider UI manager),
81+
place it under `gui/components/`, not `gui/widgets/` or a specific page module.
82+
83+
Page rendering rule:
84+
- For pages rendered as custom widget bodies, use shared page-level helpers
85+
(for example, `render_scroll_area_view`) to keep route rendering behavior
86+
consistent and avoid duplicated setup code.
87+
88+
Provider-scoped vs multi-provider presentation:
89+
- Multi-provider pages should keep source-identifying affordances.
90+
- Provider-scoped pages should prefer cleaner headers and avoid redundant
91+
source decorations.
92+
93+
Responsive layout rule:
94+
- Let a page own its responsive reflow logic based on its own available width.
95+
- Avoid parent-coupled resize orchestration unless there is a proven structural
96+
need.
97+
98+
## 6) Workflow
99+
100+
Keep a lightweight todo list for the current task:
101+
- Update it before/after each meaningful step.
102+
- Mark items done as soon as they are completed.
103+
- Save it under `.tasks/` (for example, `.tasks/todo.md`).
104+
105+
Keep a short proposal note for design changes:
106+
- Capture the intended approach, tradeoffs, and assumptions.
107+
- Use it to confirm alignment before coding.
108+
- Save it under `.tasks/` (for example, `.tasks/proposal.md`).
109+
110+
Minimal templates:
111+
112+
Todo:
113+
- [ ] Step 1
114+
- [ ] Step 2
115+
116+
Proposal:
117+
- Approach: ...
118+
- Tradeoffs: ...
119+
- Assumptions: ...

feeluown/gui/components/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
from .song_tag import SongSourceTag # noqa
1313
from .collections import CollectionListView # noqa
1414
from .player_progress import PlayerProgressSliderAndLabel # noqa
15+
from .recommendation_panel import Panel, RecPlaylistsPanel # noqa
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from typing import TYPE_CHECKING
2+
3+
from PyQt6.QtCore import Qt
4+
from PyQt6.QtGui import QPixmap, QGuiApplication
5+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
6+
7+
from feeluown.i18n import t
8+
from feeluown.library import SupportsRecListDailyPlaylists
9+
from feeluown.utils.reader import create_reader
10+
from feeluown.utils.aio import run_fn
11+
from feeluown.gui.widgets.header import LargeHeader
12+
from feeluown.gui.widgets.img_card_list import (
13+
PlaylistCardListView,
14+
PlaylistCardListModel,
15+
PlaylistFilterProxyModel,
16+
PlaylistCardListDelegate,
17+
)
18+
from feeluown.gui.widgets.selfpaint_btn import TriagleButton
19+
20+
if TYPE_CHECKING:
21+
from feeluown.app.gui_app import GuiApp
22+
23+
24+
PlaylistCardMinWidth = 140
25+
PlaylistCardSpacing = 25
26+
27+
28+
class FoldButton(TriagleButton):
29+
def __init__(self, *args, **kwargs):
30+
super().__init__(*args, **kwargs)
31+
self.setToolTip(t("fold-tooltip"))
32+
self.setCheckable(True)
33+
self.toggled.connect(self.on_toggled)
34+
# Checked means folded, and show down direction. Click to unfold.
35+
self.setChecked(True)
36+
37+
def on_toggled(self, checked):
38+
self.setToolTip(
39+
t("fold-expand") if checked else t("fold-collapse"),
40+
)
41+
self.set_direction("down" if checked else "up")
42+
43+
44+
class Panel(QWidget):
45+
# Shared panel chrome for homepage/discovery recommendation blocks.
46+
_id_pixmap_cache = {}
47+
48+
def __init__(self, title, body, pixmap, *, show_icon: bool = True):
49+
super().__init__(parent=None)
50+
51+
self.icon_label = QLabel()
52+
self.header = LargeHeader(title)
53+
self.body = body
54+
55+
self.icon_label.setFixedSize(20, 20)
56+
self.icon_label.setPixmap(pixmap)
57+
58+
self._layout = QVBoxLayout(self)
59+
self._layout.setContentsMargins(0, 0, 0, 0)
60+
self._layout.setSpacing(10)
61+
62+
self._h_layout = QHBoxLayout()
63+
self._h_layout.setSpacing(5)
64+
self._layout.addLayout(self._h_layout)
65+
# Homepage may aggregate panels from different providers, while
66+
# provider-scoped pages can hide this icon for a cleaner header.
67+
if show_icon:
68+
self._h_layout.addWidget(self.icon_label)
69+
else:
70+
self.icon_label.hide()
71+
self._h_layout.addWidget(self.header)
72+
self._h_layout.addStretch(1)
73+
74+
self.fold_unfold_btn = FoldButton(length=16)
75+
self._h_layout.addWidget(self.fold_unfold_btn)
76+
self._h_layout.addSpacing(20)
77+
78+
self._layout.addWidget(self.body)
79+
80+
@classmethod
81+
def get_provider_pixmap(cls, app: "GuiApp", provider_id, width=20):
82+
device_pixel_ratio = QGuiApplication.instance().devicePixelRatio()
83+
if provider_id in cls._id_pixmap_cache:
84+
return cls._id_pixmap_cache[provider_id]
85+
pvd_ui = app.pvd_ui_mgr.get(provider_id)
86+
if pvd_ui is None:
87+
svg = "icons:feeluown.png"
88+
else:
89+
svg = pvd_ui.get_colorful_svg()
90+
pixmap = QPixmap(svg).scaledToWidth(
91+
int(width * device_pixel_ratio), Qt.TransformationMode.SmoothTransformation
92+
)
93+
pixmap.setDevicePixelRatio(device_pixel_ratio)
94+
cls._id_pixmap_cache[provider_id] = pixmap
95+
return pixmap
96+
97+
async def render(self):
98+
pass
99+
100+
101+
class RecPlaylistsPanel(Panel):
102+
def __init__(
103+
self,
104+
app: "GuiApp",
105+
provider: SupportsRecListDailyPlaylists,
106+
*,
107+
initial_row_count: int = 2,
108+
show_icon: bool = True,
109+
):
110+
self._provider = provider
111+
self._app = app
112+
self._initial_row_count = initial_row_count
113+
self.playlist_list_view = PlaylistCardListView(
114+
no_scroll_v=True, fixed_row_count=self._initial_row_count
115+
)
116+
# Bind once at init to avoid duplicate signal connections after re-render.
117+
self.playlist_list_view.show_playlist_needed.connect(
118+
lambda model: self._app.browser.goto(model=model)
119+
)
120+
# Keep card visual style consistent between homepage and discovery page.
121+
self.playlist_list_view.setItemDelegate(
122+
PlaylistCardListDelegate(
123+
self.playlist_list_view,
124+
card_min_width=PlaylistCardMinWidth,
125+
card_spacing=PlaylistCardSpacing,
126+
)
127+
)
128+
pixmap = Panel.get_provider_pixmap(app, provider.identifier)
129+
super().__init__(
130+
t("recommended-playlist"),
131+
self.playlist_list_view,
132+
pixmap,
133+
show_icon=show_icon,
134+
)
135+
136+
self.fold_unfold_btn.clicked.connect(self._show_more_or_less)
137+
138+
def _show_more_or_less(self, checked):
139+
# Folded keeps a predictable compact height; unfolded shows all rows.
140+
if checked:
141+
self.playlist_list_view.set_fixed_row_count(self._initial_row_count)
142+
else:
143+
self.playlist_list_view.set_fixed_row_count(-1)
144+
145+
async def render(self):
146+
playlists = await run_fn(self._provider.rec_list_daily_playlists)
147+
if not playlists:
148+
return
149+
model = PlaylistCardListModel.create(create_reader(playlists), self._app)
150+
filter_model = PlaylistFilterProxyModel()
151+
filter_model.setSourceModel(model)
152+
self.playlist_list_view.setModel(filter_model)

0 commit comments

Comments
 (0)