ms-components 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ms_components/__init__.py +0 -0
- ms_components/base.qss +252 -0
- ms_components/listview.py +1287 -0
- ms_components/ms_dockwidget/__init__.py +0 -0
- ms_components/ms_dockwidget/resources/__init__.py +1 -0
- ms_components/ms_dockwidget/resources/icons/__init__.py +10 -0
- ms_components/ms_dockwidget/resources/icons/folder.svg +5 -0
- ms_components/ms_dockwidget/widget.py +1975 -0
- ms_components/ms_monitor/__init__.py +43 -0
- ms_components/ms_monitor/bridge.py +400 -0
- ms_components/ms_monitor/cards.py +75 -0
- ms_components/ms_monitor/chunk_panel.py +85 -0
- ms_components/ms_monitor/config.py +20 -0
- ms_components/ms_monitor/events_panel.py +178 -0
- ms_components/ms_monitor/executor_panel.py +149 -0
- ms_components/ms_monitor/executors_tab.py +355 -0
- ms_components/ms_monitor/formatting.py +51 -0
- ms_components/ms_monitor/job_cards.py +536 -0
- ms_components/ms_monitor/job_filter_proxy.py +48 -0
- ms_components/ms_monitor/job_tree_model.py +106 -0
- ms_components/ms_monitor/models.py +165 -0
- ms_components/ms_monitor/palette.py +41 -0
- ms_components/ms_monitor/polling.py +498 -0
- ms_components/ms_monitor/widget.py +401 -0
- ms_components/ms_projects/__init__.py +4 -0
- ms_components/ms_projects/app.py +18 -0
- ms_components/ms_projects/browser.py +2132 -0
- ms_components/ms_projects/scratch.py +772 -0
- ms_components/ms_projects/widget.py +284 -0
- ms_components/ms_pymol/__init__.py +13 -0
- ms_components/ms_pymol/icons/actions.svg +1 -0
- ms_components/ms_pymol/icons/box.svg +1 -0
- ms_components/ms_pymol/icons/center.svg +1 -0
- ms_components/ms_pymol/icons/display.svg +4 -0
- ms_components/ms_pymol/icons/hide.svg +1 -0
- ms_components/ms_pymol/icons/menu.svg +5 -0
- ms_components/ms_pymol/icons/orient.svg +11 -0
- ms_components/ms_pymol/icons/presets.svg +7 -0
- ms_components/ms_pymol/icons/rock.svg +6 -0
- ms_components/ms_pymol/icons/selection.svg +11 -0
- ms_components/ms_pymol/icons/settings.svg +4 -0
- ms_components/ms_pymol/icons/zoom.svg +6 -0
- ms_components/ms_pymol/plugins.py +158 -0
- ms_components/ms_pymol/widget.py +700 -0
- ms_components/ms_settings/__init__.py +3 -0
- ms_components/ms_settings/panel.py +899 -0
- ms_components/ms_settings/workers_editor.py +335 -0
- ms_components/ms_stepper.py +1106 -0
- ms_components/ms_table/__init__.py +67 -0
- ms_components/ms_table/action_header.py +502 -0
- ms_components/ms_table/cell_delegate.py +278 -0
- ms_components/ms_table/column_panel.py +120 -0
- ms_components/ms_table/example_usage.py +376 -0
- ms_components/ms_table/filter_panel.py +326 -0
- ms_components/ms_table/icons/columns.svg +5 -0
- ms_components/ms_table/icons/export.svg +6 -0
- ms_components/ms_table/icons/filter.svg +1 -0
- ms_components/ms_table/icons/menu.svg +5 -0
- ms_components/ms_table/icons/reload.svg +4 -0
- ms_components/ms_table/icons/search.svg +1 -0
- ms_components/ms_table/icons/settings.svg +4 -0
- ms_components/ms_table/icons/sort-asc.svg +1 -0
- ms_components/ms_table/icons/sort-desc.svg +1 -0
- ms_components/ms_table/icons/sort.svg +1 -0
- ms_components/ms_table/metric_filter_bar.py +232 -0
- ms_components/ms_table/pagination_control.py +178 -0
- ms_components/ms_table/query_builder.py +545 -0
- ms_components/ms_table/session_provider.py +11 -0
- ms_components/ms_table/smart_table_view.py +1378 -0
- ms_components/ms_table/sort_panel.py +171 -0
- ms_components/ms_table/table_config.py +394 -0
- ms_components/ms_table/table_model.py +329 -0
- ms_components/ms_table/test_external_filters.py +66 -0
- ms_components/ms_table/test_toolbar.py +130 -0
- ms_components/pagination.py +221 -0
- ms_components/step_dialog.py +135 -0
- ms_components/theme.py +346 -0
- ms_components/tool_panel.py +480 -0
- ms_components/wheel.py +66 -0
- ms_components-0.1.0.dist-info/METADATA +47 -0
- ms_components-0.1.0.dist-info/RECORD +84 -0
- ms_components-0.1.0.dist-info/WHEEL +5 -0
- ms_components-0.1.0.dist-info/licenses/LICENSE +21 -0
- ms_components-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
|
|
2
|
+
import hashlib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field
|
|
5
|
+
from typing import List, Optional, Callable, Set, Hashable, Type
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import (Qt, QAbstractListModel, QModelIndex, QEvent, QRect, QSize, Signal)
|
|
8
|
+
from PySide6.QtGui import QColor, QPalette
|
|
9
|
+
from PySide6.QtGui import (QCursor, QPainter, QFont, QPen, QBrush, QPainterPath, QMouseEvent)
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QStyledItemDelegate
|
|
11
|
+
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QListView,
|
|
12
|
+
QLineEdit, QLabel,
|
|
13
|
+
QAbstractItemView, QToolButton, QMenu)
|
|
14
|
+
|
|
15
|
+
from .pagination import PaginationControl
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ThemeColors:
|
|
20
|
+
# -- Backgrounds ---------------------------------------------
|
|
21
|
+
bg_base: QColor # Normal background of each item
|
|
22
|
+
bg_hover: QColor # Hover
|
|
23
|
+
bg_selected: QColor # Multi-selection (opaque, no alpha)
|
|
24
|
+
bg_selected_border: QColor # Left border when selected
|
|
25
|
+
|
|
26
|
+
# -- Checkbox ------------------------------------------------
|
|
27
|
+
checkbox_empty_bg: QColor
|
|
28
|
+
checkbox_empty_border: QColor
|
|
29
|
+
checkbox_hover_bg: QColor
|
|
30
|
+
checkbox_hover_border: QColor
|
|
31
|
+
checkbox_checked_bg: QColor # = primary
|
|
32
|
+
checkbox_check_mark: QColor # Check-mark colour
|
|
33
|
+
|
|
34
|
+
# -- Neutral tags / badges -----------------------------------
|
|
35
|
+
tag_bg: QColor
|
|
36
|
+
tag_text: QColor
|
|
37
|
+
tag_border: QColor
|
|
38
|
+
|
|
39
|
+
# -- Text ---------------------------------------------------
|
|
40
|
+
text_primary: QColor
|
|
41
|
+
text_secondary: QColor
|
|
42
|
+
text_muted: QColor
|
|
43
|
+
text_on_primary: QColor
|
|
44
|
+
|
|
45
|
+
# -- Accents -------------------------------------------------
|
|
46
|
+
primary: QColor
|
|
47
|
+
primary_light: QColor
|
|
48
|
+
danger: QColor
|
|
49
|
+
|
|
50
|
+
# -- Borders -------------------------------------------------
|
|
51
|
+
border_item: QColor # Row separator
|
|
52
|
+
border_input: QColor # Input border
|
|
53
|
+
|
|
54
|
+
# -- Side indicator ------------------------------------------
|
|
55
|
+
indicator_selected: QColor
|
|
56
|
+
indicator_hover: QColor
|
|
57
|
+
|
|
58
|
+
# -- Info publica --------------------------------------------
|
|
59
|
+
is_dark: bool
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------
|
|
62
|
+
@classmethod
|
|
63
|
+
def get(cls, widget: Optional[QWidget] = None) -> "ThemeColors":
|
|
64
|
+
# Some widgets can be left holding stale local palettes, so the app's
|
|
65
|
+
# global palette is the source of truth for the active theme.
|
|
66
|
+
app = QApplication.instance()
|
|
67
|
+
theme_mode = ""
|
|
68
|
+
if app is not None:
|
|
69
|
+
palette = app.palette()
|
|
70
|
+
theme_mode = str(app.property("theme_mode") or "").lower()
|
|
71
|
+
elif widget is not None:
|
|
72
|
+
palette = widget.palette()
|
|
73
|
+
else:
|
|
74
|
+
palette = QPalette()
|
|
75
|
+
|
|
76
|
+
primary = cls._primary_from_palette(palette)
|
|
77
|
+
if theme_mode in {"dark", "light"}:
|
|
78
|
+
is_dark = theme_mode == "dark"
|
|
79
|
+
else:
|
|
80
|
+
is_dark = cls._detect_dark(palette)
|
|
81
|
+
|
|
82
|
+
return (cls._build_dark(palette, primary)
|
|
83
|
+
if is_dark
|
|
84
|
+
else cls._build_light(palette, primary))
|
|
85
|
+
|
|
86
|
+
# ── Deteccion ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _detect_dark(palette: QPalette) -> bool:
|
|
90
|
+
window = palette.color(QPalette.Window)
|
|
91
|
+
window_text = palette.color(QPalette.WindowText)
|
|
92
|
+
if abs(window.lightness() - window_text.lightness()) >= 80:
|
|
93
|
+
return window_text.lightness() > window.lightness()
|
|
94
|
+
return window.lightness() < 128
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _primary_from_palette(palette: QPalette) -> QColor:
|
|
98
|
+
h = palette.color(QPalette.Highlight)
|
|
99
|
+
return h if h.saturation() >= 30 else QColor("#2979ff")
|
|
100
|
+
|
|
101
|
+
# ── Construccion tema oscuro ─────────────────────────────────
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _build_dark(cls, palette: QPalette, primary: QColor) -> "ThemeColors":
|
|
105
|
+
bg = cls._surface_bg_from_palette(palette)
|
|
106
|
+
text = cls._text_from_palette(palette)
|
|
107
|
+
|
|
108
|
+
bg_sel = _blend(bg, primary, 0.15)
|
|
109
|
+
tag_bg = _blend(bg, QColor(255, 255, 255), 0.13)
|
|
110
|
+
cb_border = _blend(bg, QColor(255, 255, 255), 0.30)
|
|
111
|
+
cb_hov_bg = _blend(bg, QColor(255, 255, 255), 0.10)
|
|
112
|
+
cb_hov_bdr = _blend(bg, QColor(255, 255, 255), 0.50)
|
|
113
|
+
|
|
114
|
+
return cls(
|
|
115
|
+
bg_base=bg,
|
|
116
|
+
bg_hover=_blend(bg, QColor(255, 255, 255), 0.06),
|
|
117
|
+
bg_selected=bg_sel,
|
|
118
|
+
bg_selected_border=primary,
|
|
119
|
+
|
|
120
|
+
checkbox_empty_bg=bg, # theme background, NOT white
|
|
121
|
+
checkbox_empty_border=cb_border,
|
|
122
|
+
checkbox_hover_bg=cb_hov_bg,
|
|
123
|
+
checkbox_hover_border=cb_hov_bdr,
|
|
124
|
+
checkbox_checked_bg=primary,
|
|
125
|
+
checkbox_check_mark=QColor("#ffffff"),
|
|
126
|
+
|
|
127
|
+
tag_bg=tag_bg,
|
|
128
|
+
tag_text=_with_alpha(text, 210),
|
|
129
|
+
tag_border=_blend(bg, QColor(255, 255, 255), 0.20),
|
|
130
|
+
|
|
131
|
+
text_primary=text,
|
|
132
|
+
text_secondary=_with_alpha(text, 180),
|
|
133
|
+
text_muted=_with_alpha(text, 110),
|
|
134
|
+
text_on_primary=QColor("#ffffff"),
|
|
135
|
+
|
|
136
|
+
primary=primary,
|
|
137
|
+
primary_light=primary.lighter(140),
|
|
138
|
+
danger=QColor("#cf6679"),
|
|
139
|
+
|
|
140
|
+
border_item=_blend(bg, QColor(255, 255, 255), 0.09),
|
|
141
|
+
border_input=_blend(bg, QColor(255, 255, 255), 0.25),
|
|
142
|
+
|
|
143
|
+
indicator_selected=primary,
|
|
144
|
+
indicator_hover=_with_alpha(primary, 130),
|
|
145
|
+
|
|
146
|
+
is_dark=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def _build_light(cls, palette: QPalette, primary: QColor) -> "ThemeColors":
|
|
151
|
+
bg = cls._surface_bg_from_palette(palette)
|
|
152
|
+
text = cls._text_from_palette(palette)
|
|
153
|
+
if bg.lightness() < 140:
|
|
154
|
+
bg = QColor("#f3f5f7")
|
|
155
|
+
if text.lightness() > 170:
|
|
156
|
+
text = QColor("#1f2328")
|
|
157
|
+
bg_sel = _blend(bg, primary, 0.10)
|
|
158
|
+
|
|
159
|
+
return cls(
|
|
160
|
+
bg_base=bg,
|
|
161
|
+
bg_hover=_blend(bg, QColor(0, 0, 0), 0.04),
|
|
162
|
+
bg_selected=_blend(bg, primary, 0.10),
|
|
163
|
+
bg_selected_border=primary,
|
|
164
|
+
|
|
165
|
+
checkbox_empty_bg=bg,
|
|
166
|
+
checkbox_empty_border=QColor("#adb5bd"),
|
|
167
|
+
checkbox_hover_bg=_blend(bg, QColor(0, 0, 0), 0.06),
|
|
168
|
+
checkbox_hover_border=QColor("#6c757d"),
|
|
169
|
+
checkbox_checked_bg=primary,
|
|
170
|
+
checkbox_check_mark=QColor("#ffffff"),
|
|
171
|
+
|
|
172
|
+
tag_bg=_blend(bg, QColor(0, 0, 0), 0.08),
|
|
173
|
+
tag_text=_with_alpha(text, 220),
|
|
174
|
+
tag_border=_blend(bg, QColor(0, 0, 0), 0.15),
|
|
175
|
+
|
|
176
|
+
text_primary=text,
|
|
177
|
+
text_secondary=_with_alpha(text, 180),
|
|
178
|
+
text_muted=_with_alpha(text, 120),
|
|
179
|
+
text_on_primary=QColor("#ffffff"),
|
|
180
|
+
|
|
181
|
+
primary=primary,
|
|
182
|
+
primary_light=primary.lighter(170),
|
|
183
|
+
danger=QColor("#dc3545"),
|
|
184
|
+
|
|
185
|
+
border_item=_blend(bg, QColor(0, 0, 0), 0.10),
|
|
186
|
+
border_input=_blend(bg, QColor(0, 0, 0), 0.15),
|
|
187
|
+
|
|
188
|
+
indicator_selected=primary,
|
|
189
|
+
indicator_hover=_with_alpha(primary, 120),
|
|
190
|
+
|
|
191
|
+
is_dark=False,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _surface_bg_from_palette(palette: QPalette) -> QColor:
|
|
196
|
+
base = palette.color(QPalette.Base)
|
|
197
|
+
window = palette.color(QPalette.Window)
|
|
198
|
+
if abs(base.lightness() - window.lightness()) > 90:
|
|
199
|
+
return window
|
|
200
|
+
return base
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _text_from_palette(palette: QPalette) -> QColor:
|
|
204
|
+
base = palette.color(QPalette.Base)
|
|
205
|
+
window = palette.color(QPalette.Window)
|
|
206
|
+
if abs(base.lightness() - window.lightness()) > 90:
|
|
207
|
+
return palette.color(QPalette.WindowText)
|
|
208
|
+
return palette.color(QPalette.Text)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# -- Utilidades --------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def _blend(a: QColor, b: QColor, t: float) -> QColor:
|
|
214
|
+
"""Linear blend. t=0->a, t=1->b. Result is always opaque."""
|
|
215
|
+
|
|
216
|
+
def ch(ca, cb):
|
|
217
|
+
return max(0, min(255, int(ca + (cb - ca) * t)))
|
|
218
|
+
|
|
219
|
+
return QColor(
|
|
220
|
+
ch(a.red(), b.red()),
|
|
221
|
+
ch(a.green(), b.green()),
|
|
222
|
+
ch(a.blue(), b.blue()),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _with_alpha(color: QColor, alpha: int) -> QColor:
|
|
227
|
+
c = QColor(color)
|
|
228
|
+
c.setAlpha(max(0, min(255, alpha)))
|
|
229
|
+
return c
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class ProjectItem:
|
|
234
|
+
"""Data model for a project."""
|
|
235
|
+
id: Hashable
|
|
236
|
+
name: str
|
|
237
|
+
description: str
|
|
238
|
+
path: str
|
|
239
|
+
last_modified: str
|
|
240
|
+
favorite: bool = False
|
|
241
|
+
tags: List[str] = field(default_factory=list)
|
|
242
|
+
language: Optional[str] = None
|
|
243
|
+
color: Optional[str] = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ProjectListModel(QAbstractListModel):
|
|
247
|
+
"""Model for the project list, with multi-selection support."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, parent=None):
|
|
250
|
+
super().__init__(parent)
|
|
251
|
+
self._projects: List[ProjectItem] = []
|
|
252
|
+
self._selected_ids: Set[Hashable] = set()
|
|
253
|
+
self._active_id: Optional[Hashable] = None
|
|
254
|
+
|
|
255
|
+
def rowCount(self, parent=QModelIndex()):
|
|
256
|
+
return len(self._projects)
|
|
257
|
+
|
|
258
|
+
def data(self, index, role=Qt.DisplayRole):
|
|
259
|
+
if not index.isValid() or index.row() >= len(self._projects):
|
|
260
|
+
return None
|
|
261
|
+
project = self._projects[index.row()]
|
|
262
|
+
if role == Qt.DisplayRole:
|
|
263
|
+
return project
|
|
264
|
+
if role == Qt.UserRole + 1:
|
|
265
|
+
return project.id in self._selected_ids
|
|
266
|
+
if role == Qt.UserRole + 2:
|
|
267
|
+
return project.id == self._active_id
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def set_projects(self, projects: List[ProjectItem]):
|
|
271
|
+
"""Set the project list."""
|
|
272
|
+
self.beginResetModel()
|
|
273
|
+
self._projects = projects
|
|
274
|
+
current_ids = {p.id for p in projects}
|
|
275
|
+
self._selected_ids &= current_ids
|
|
276
|
+
if self._active_id not in current_ids:
|
|
277
|
+
self._active_id = None
|
|
278
|
+
self.endResetModel()
|
|
279
|
+
|
|
280
|
+
def get_project(self, row: int) -> Optional[ProjectItem]:
|
|
281
|
+
"""Return the project at the given index."""
|
|
282
|
+
if 0 <= row < len(self._projects):
|
|
283
|
+
return self._projects[row]
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def toggle_selection(self, project_id: Hashable):
|
|
287
|
+
"""Toggle a project's selection."""
|
|
288
|
+
if project_id in self._selected_ids:
|
|
289
|
+
self._selected_ids.remove(project_id)
|
|
290
|
+
else:
|
|
291
|
+
self._selected_ids.add(project_id)
|
|
292
|
+
self.dataChanged.emit(
|
|
293
|
+
self.index(0), self.index(self.rowCount() - 1)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def get_selected_count(self) -> int:
|
|
297
|
+
"""Return how many projects are selected."""
|
|
298
|
+
return len(self._selected_ids)
|
|
299
|
+
|
|
300
|
+
def get_selected_projects(self) -> List[ProjectItem]:
|
|
301
|
+
"""Return the list of selected projects."""
|
|
302
|
+
return [p for p in self._projects if p.id in self._selected_ids]
|
|
303
|
+
|
|
304
|
+
def set_active_project(self, project_id: Optional[Hashable]):
|
|
305
|
+
self._active_id = project_id
|
|
306
|
+
if self.rowCount() > 0:
|
|
307
|
+
self.dataChanged.emit(
|
|
308
|
+
self.index(0), self.index(self.rowCount() - 1)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def get_active_project(self) -> Optional[ProjectItem]:
|
|
312
|
+
if self._active_id is None:
|
|
313
|
+
return None
|
|
314
|
+
for project in self._projects:
|
|
315
|
+
if project.id == self._active_id:
|
|
316
|
+
return project
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def clear_selection(self):
|
|
320
|
+
"""Clear the whole selection."""
|
|
321
|
+
self._selected_ids.clear()
|
|
322
|
+
if self.rowCount() > 0:
|
|
323
|
+
self.dataChanged.emit(
|
|
324
|
+
self.index(0), self.index(self.rowCount() - 1)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def select_all(self):
|
|
328
|
+
self._selected_ids = {p.id for p in self._projects}
|
|
329
|
+
if self.rowCount() > 0:
|
|
330
|
+
self.dataChanged.emit(
|
|
331
|
+
self.index(0), self.index(self.rowCount() - 1)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def are_all_selected(self) -> bool:
|
|
335
|
+
return bool(self._projects) and len(self._selected_ids) == len(self._projects)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Language brand colours - these ARE fixed (technology identity, not theme)
|
|
340
|
+
LANG_COLORS: dict[str, tuple[str, str]] = {
|
|
341
|
+
"Python": ("#3776ab", "#ffffff"),
|
|
342
|
+
"JavaScript": ("#f7df1e", "#111111"),
|
|
343
|
+
"TypeScript": ("#3178c6", "#ffffff"),
|
|
344
|
+
"Java": ("#007396", "#ffffff"),
|
|
345
|
+
"C++": ("#00599c", "#ffffff"),
|
|
346
|
+
"Go": ("#00add8", "#ffffff"),
|
|
347
|
+
"Rust": ("#ce422b", "#ffffff"),
|
|
348
|
+
"Kotlin": ("#7f52ff", "#ffffff"),
|
|
349
|
+
"Swift": ("#f05138", "#ffffff"),
|
|
350
|
+
"Ruby": ("#cc342d", "#ffffff"),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
AVATAR_COLORS: tuple[str, ...] = (
|
|
354
|
+
"#4e95ff", # blue
|
|
355
|
+
"#a259ff", # violet
|
|
356
|
+
"#ff6b6b", # red
|
|
357
|
+
"#43d9ad", # teal
|
|
358
|
+
"#ffb347", # amber
|
|
359
|
+
"#f7768e", # pink
|
|
360
|
+
"#9ece6a", # green
|
|
361
|
+
"#7dcfff", # sky
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _avatar_color_for(project: "ProjectItem") -> QColor:
|
|
366
|
+
"""
|
|
367
|
+
Return the avatar color for a project.
|
|
368
|
+
Priority: project.color (set by caller per-tool) > hash-based fallback.
|
|
369
|
+
"""
|
|
370
|
+
if project.color:
|
|
371
|
+
return QColor(project.color)
|
|
372
|
+
seed = f"{project.id}|{project.name}".encode("utf-8", errors="ignore")
|
|
373
|
+
digest = hashlib.md5(seed).digest()
|
|
374
|
+
return QColor(AVATAR_COLORS[digest[0] % len(AVATAR_COLORS)])
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class ProjectItemDelegate(QStyledItemDelegate):
|
|
378
|
+
"""
|
|
379
|
+
Theme-adaptive delegate for the project list.
|
|
380
|
+
|
|
381
|
+
Features:
|
|
382
|
+
- Every base colour comes from ThemeColors.get() - nothing hardcoded.
|
|
383
|
+
- The avatar uses project.color when set (per-tool colour), otherwise a
|
|
384
|
+
colour derived from the hash of the id/name as a fallback.
|
|
385
|
+
- Inline "Open" and "★" buttons, visible only on hover (hover-reveal).
|
|
386
|
+
- Clicking an inline button emits open_clicked / favorite_clicked.
|
|
387
|
+
- Agnostic: can be subclassed or replaced by an external delegate.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
checkbox_clicked = Signal(int, object) # (row, project_id)
|
|
391
|
+
open_clicked = Signal(object) # ProjectItem
|
|
392
|
+
favorite_clicked = Signal(object) # ProjectItem
|
|
393
|
+
|
|
394
|
+
# Inline button geometry (relative to the item rect)
|
|
395
|
+
_BTN_W = 60 # "Open" button width
|
|
396
|
+
_BTN_H = 26
|
|
397
|
+
_STAR_W = 28 # "★" button width
|
|
398
|
+
_BTN_GAP = 6 # gap between buttons
|
|
399
|
+
_BTN_R = 6 # border-radius
|
|
400
|
+
_PAD_RIGHT = 14 # right margin
|
|
401
|
+
|
|
402
|
+
def __init__(self, parent=None):
|
|
403
|
+
super().__init__(parent)
|
|
404
|
+
self.hover_row = -1
|
|
405
|
+
self.hover_checkbox = -1
|
|
406
|
+
self.show_checkboxes = False
|
|
407
|
+
self._hover_open = False # cursor over the Open button
|
|
408
|
+
self._hover_star = False # cursor over the ★ button
|
|
409
|
+
|
|
410
|
+
# ── State setters ────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def set_hover_row(self, row: int):
|
|
413
|
+
self.hover_row = row
|
|
414
|
+
|
|
415
|
+
def set_hover_checkbox(self, row: int):
|
|
416
|
+
self.hover_checkbox = row
|
|
417
|
+
|
|
418
|
+
def set_show_checkboxes(self, enabled: bool):
|
|
419
|
+
self.show_checkboxes = enabled
|
|
420
|
+
|
|
421
|
+
# ── Geometry ─────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
def sizeHint(self, option, index):
|
|
424
|
+
return QSize(option.rect.width(), 78)
|
|
425
|
+
|
|
426
|
+
def get_checkbox_rect(self, rect: QRect) -> QRect:
|
|
427
|
+
if not self.show_checkboxes:
|
|
428
|
+
return QRect()
|
|
429
|
+
return QRect(rect.left() + 15, rect.top() + (rect.height() - 22) // 2, 22, 22)
|
|
430
|
+
|
|
431
|
+
def _get_open_btn_rect(self, rect: QRect) -> QRect:
|
|
432
|
+
"""Rect of the 'Open' button within the item rect."""
|
|
433
|
+
x = rect.right() - self._PAD_RIGHT - self._STAR_W - self._BTN_GAP - self._BTN_W
|
|
434
|
+
y = rect.center().y() - self._BTN_H // 2
|
|
435
|
+
return QRect(x, y, self._BTN_W, self._BTN_H)
|
|
436
|
+
|
|
437
|
+
def _get_star_btn_rect(self, rect: QRect) -> QRect:
|
|
438
|
+
"""Rect of the '★' button within the item rect."""
|
|
439
|
+
x = rect.right() - self._PAD_RIGHT - self._STAR_W
|
|
440
|
+
y = rect.center().y() - self._BTN_H // 2
|
|
441
|
+
return QRect(x, y, self._STAR_W, self._BTN_H)
|
|
442
|
+
|
|
443
|
+
# ── Paint ────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
def paint(self, painter: QPainter, option, index):
|
|
446
|
+
painter.save()
|
|
447
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
448
|
+
|
|
449
|
+
project = index.data(Qt.DisplayRole)
|
|
450
|
+
is_selected = bool(index.data(Qt.UserRole + 1))
|
|
451
|
+
is_active = bool(index.data(Qt.UserRole + 2))
|
|
452
|
+
if not project:
|
|
453
|
+
painter.restore()
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
rect = option.rect
|
|
457
|
+
colors = ThemeColors.get()
|
|
458
|
+
is_hovered = self.hover_row == index.row()
|
|
459
|
+
|
|
460
|
+
# ── Fondo ────────────────────────────────────────────────
|
|
461
|
+
if is_selected or is_active:
|
|
462
|
+
painter.fillRect(rect, colors.bg_selected)
|
|
463
|
+
elif is_hovered:
|
|
464
|
+
painter.fillRect(rect, colors.bg_hover)
|
|
465
|
+
else:
|
|
466
|
+
painter.fillRect(rect, colors.bg_base)
|
|
467
|
+
|
|
468
|
+
# ── Separador inferior ───────────────────────────────────
|
|
469
|
+
painter.setPen(QPen(colors.border_item, 1))
|
|
470
|
+
painter.drawLine(rect.bottomLeft(), rect.bottomRight())
|
|
471
|
+
|
|
472
|
+
# ── Checkbox (modo multi-select) ─────────────────────────
|
|
473
|
+
cb_rect = self.get_checkbox_rect(rect)
|
|
474
|
+
if self.show_checkboxes:
|
|
475
|
+
self._paint_checkbox(painter, cb_rect, colors, is_selected, index.row())
|
|
476
|
+
|
|
477
|
+
# ── Avatar with per-tool colour ──────────────────────────
|
|
478
|
+
icon_x = cb_rect.right() + 15 if self.show_checkboxes else rect.left() + 18
|
|
479
|
+
icon_rect = QRect(icon_x, rect.top() + (rect.height() - 40) // 2, 40, 40)
|
|
480
|
+
self._paint_avatar(painter, icon_rect, project)
|
|
481
|
+
|
|
482
|
+
# ── Text - reserve room for the inline buttons ──────────
|
|
483
|
+
text_x = icon_rect.right() + 12
|
|
484
|
+
# On hover, reserve width for the buttons; otherwise use the full width
|
|
485
|
+
if is_hovered:
|
|
486
|
+
btn_reserve = self._BTN_W + self._STAR_W + self._BTN_GAP + self._PAD_RIGHT + 12
|
|
487
|
+
else:
|
|
488
|
+
btn_reserve = self._STAR_W + self._PAD_RIGHT + 6 # always reserve ★
|
|
489
|
+
text_width = rect.width() - (text_x - rect.left()) - btn_reserve
|
|
490
|
+
self._paint_text(painter, rect, text_x, text_width, project, colors)
|
|
491
|
+
|
|
492
|
+
# ── ★ favourite button - always present, dimmed if not fav ─
|
|
493
|
+
star_rect = self._get_star_btn_rect(rect)
|
|
494
|
+
self._paint_star_btn(painter, star_rect, project, colors, is_hovered)
|
|
495
|
+
|
|
496
|
+
# ── "Open" button - hover only ─────────────────────────
|
|
497
|
+
if is_hovered:
|
|
498
|
+
open_rect = self._get_open_btn_rect(rect)
|
|
499
|
+
self._paint_open_btn(painter, open_rect, colors)
|
|
500
|
+
|
|
501
|
+
# ── Indicador lateral ────────────────────────────────────
|
|
502
|
+
if is_selected or is_active:
|
|
503
|
+
painter.setPen(Qt.NoPen)
|
|
504
|
+
painter.setBrush(QBrush(colors.indicator_selected))
|
|
505
|
+
painter.drawRect(QRect(rect.left(), rect.top(), 4, rect.height()))
|
|
506
|
+
elif is_hovered:
|
|
507
|
+
painter.setPen(Qt.NoPen)
|
|
508
|
+
painter.setBrush(QBrush(colors.indicator_hover))
|
|
509
|
+
painter.drawRect(QRect(rect.left(), rect.top(), 3, rect.height()))
|
|
510
|
+
|
|
511
|
+
painter.restore()
|
|
512
|
+
|
|
513
|
+
def _paint_checkbox(self, painter, cb_rect, colors: ThemeColors, is_checked, row):
|
|
514
|
+
if is_checked:
|
|
515
|
+
painter.setBrush(QBrush(colors.checkbox_checked_bg))
|
|
516
|
+
painter.setPen(Qt.NoPen)
|
|
517
|
+
painter.drawRoundedRect(cb_rect, 4, 4)
|
|
518
|
+
painter.setPen(QPen(colors.checkbox_check_mark, 2))
|
|
519
|
+
path = QPainterPath()
|
|
520
|
+
path.moveTo(cb_rect.left() + 5, cb_rect.center().y())
|
|
521
|
+
path.lineTo(cb_rect.center().x() - 1, cb_rect.bottom() - 6)
|
|
522
|
+
path.lineTo(cb_rect.right() - 4, cb_rect.top() + 6)
|
|
523
|
+
painter.drawPath(path)
|
|
524
|
+
elif row == self.hover_checkbox:
|
|
525
|
+
painter.setBrush(QBrush(colors.checkbox_hover_bg))
|
|
526
|
+
painter.setPen(QPen(colors.checkbox_hover_border, 1.5))
|
|
527
|
+
painter.drawRoundedRect(cb_rect, 4, 4)
|
|
528
|
+
else:
|
|
529
|
+
painter.setBrush(QBrush(colors.checkbox_empty_bg))
|
|
530
|
+
painter.setPen(QPen(colors.checkbox_empty_border, 1.5))
|
|
531
|
+
painter.drawRoundedRect(cb_rect, 4, 4)
|
|
532
|
+
|
|
533
|
+
def _paint_avatar(self, painter: QPainter, icon_rect: QRect, project: "ProjectItem"):
|
|
534
|
+
"""Circular avatar with a per-tool colour (project.color) or hash fallback."""
|
|
535
|
+
avatar_rect = icon_rect.adjusted(2, 2, -2, -2)
|
|
536
|
+
color = _avatar_color_for(project)
|
|
537
|
+
|
|
538
|
+
painter.setPen(Qt.NoPen)
|
|
539
|
+
painter.setBrush(QBrush(color))
|
|
540
|
+
painter.drawEllipse(avatar_rect)
|
|
541
|
+
|
|
542
|
+
text_color = QColor("#202124") if color.lightness() > 150 else QColor("#ffffff")
|
|
543
|
+
painter.setPen(text_color)
|
|
544
|
+
painter.setFont(QFont("", 12, QFont.Bold))
|
|
545
|
+
initial = (project.name or "?").strip()[:1].upper()
|
|
546
|
+
painter.drawText(avatar_rect, Qt.AlignCenter, initial)
|
|
547
|
+
|
|
548
|
+
def _paint_text(self, painter, rect, text_x, text_width, project, colors: ThemeColors):
|
|
549
|
+
top = rect.top() + 10
|
|
550
|
+
|
|
551
|
+
# Name
|
|
552
|
+
painter.setFont(QFont("", 11, QFont.Bold))
|
|
553
|
+
painter.setPen(colors.text_primary)
|
|
554
|
+
name_rect = QRect(text_x, top, text_width, 20)
|
|
555
|
+
painter.drawText(name_rect, Qt.AlignLeft | Qt.AlignVCenter, project.name or "")
|
|
556
|
+
|
|
557
|
+
# ID + description
|
|
558
|
+
painter.setFont(QFont("", 9))
|
|
559
|
+
painter.setPen(colors.text_secondary)
|
|
560
|
+
desc = f"ID: {project.id}"
|
|
561
|
+
if project.description:
|
|
562
|
+
desc = f"{desc} | {project.description}"
|
|
563
|
+
painter.drawText(QRect(text_x, top + 22, text_width, 16),
|
|
564
|
+
Qt.AlignLeft | Qt.AlignVCenter, desc)
|
|
565
|
+
|
|
566
|
+
# Date and path
|
|
567
|
+
painter.setPen(colors.text_muted)
|
|
568
|
+
painter.setFont(QFont("", 8))
|
|
569
|
+
date_str = f"\U0001f4c5 {project.last_modified}"
|
|
570
|
+
painter.drawText(QRect(text_x, top + 40, text_width // 2, 14),
|
|
571
|
+
Qt.AlignLeft | Qt.AlignVCenter, date_str)
|
|
572
|
+
|
|
573
|
+
path_str = project.path if len(str(project.path)) <= 55 else "..." + str(project.path)[-52:]
|
|
574
|
+
painter.drawText(QRect(text_x + text_width // 2, top + 40, text_width // 2, 14),
|
|
575
|
+
Qt.AlignLeft | Qt.AlignVCenter, f"\U0001f4c2 {path_str}")
|
|
576
|
+
|
|
577
|
+
# Inline tags (small pills below the date/path, only if they fit)
|
|
578
|
+
if project.tags:
|
|
579
|
+
tag_y = top + 56
|
|
580
|
+
tag_x = text_x
|
|
581
|
+
painter.setFont(QFont("", 7))
|
|
582
|
+
for tag in project.tags[:4]:
|
|
583
|
+
tw = min(len(tag) * 6 + 10, 90)
|
|
584
|
+
tag_rect = QRect(tag_x, tag_y, tw, 14)
|
|
585
|
+
painter.setBrush(QBrush(colors.tag_bg))
|
|
586
|
+
painter.setPen(QPen(colors.tag_border, 0.8))
|
|
587
|
+
painter.drawRoundedRect(tag_rect, 3, 3)
|
|
588
|
+
painter.setPen(colors.tag_text)
|
|
589
|
+
painter.drawText(tag_rect, Qt.AlignCenter, tag)
|
|
590
|
+
tag_x += tw + 4
|
|
591
|
+
if tag_x > text_x + text_width:
|
|
592
|
+
break
|
|
593
|
+
|
|
594
|
+
def _paint_open_btn(self, painter: QPainter, btn_rect: QRect, colors: ThemeColors):
|
|
595
|
+
"""'Open' button - visible on hover only."""
|
|
596
|
+
# Semi-transparent background tinted with the primary colour
|
|
597
|
+
bg = _with_alpha(colors.primary, 35)
|
|
598
|
+
border = _with_alpha(colors.primary, 90)
|
|
599
|
+
painter.setBrush(QBrush(bg))
|
|
600
|
+
painter.setPen(QPen(border, 1))
|
|
601
|
+
path = QPainterPath()
|
|
602
|
+
path.addRoundedRect(btn_rect.x(), btn_rect.y(),
|
|
603
|
+
btn_rect.width(), btn_rect.height(),
|
|
604
|
+
self._BTN_R, self._BTN_R)
|
|
605
|
+
painter.drawPath(path)
|
|
606
|
+
|
|
607
|
+
painter.setPen(QPen(colors.primary_light, 1))
|
|
608
|
+
painter.setFont(QFont("", 9, QFont.Bold))
|
|
609
|
+
painter.drawText(btn_rect, Qt.AlignCenter, "Open")
|
|
610
|
+
|
|
611
|
+
def _paint_star_btn(self, painter: QPainter, btn_rect: QRect,
|
|
612
|
+
project: "ProjectItem", colors: ThemeColors, is_hovered: bool):
|
|
613
|
+
"""★ button - always visible, dimmed when not a favourite."""
|
|
614
|
+
if project.favorite:
|
|
615
|
+
# Activo: fondo gold tenue
|
|
616
|
+
painter.setBrush(QBrush(QColor(255, 195, 30, 30)))
|
|
617
|
+
painter.setPen(QPen(QColor(255, 195, 30, 100), 1))
|
|
618
|
+
path = QPainterPath()
|
|
619
|
+
path.addRoundedRect(btn_rect.x(), btn_rect.y(),
|
|
620
|
+
btn_rect.width(), btn_rect.height(),
|
|
621
|
+
self._BTN_R, self._BTN_R)
|
|
622
|
+
painter.drawPath(path)
|
|
623
|
+
painter.setPen(QPen(QColor(255, 195, 30, 230)))
|
|
624
|
+
elif is_hovered:
|
|
625
|
+
painter.setBrush(Qt.NoBrush)
|
|
626
|
+
painter.setPen(QPen(_with_alpha(colors.text_muted, 120)))
|
|
627
|
+
else:
|
|
628
|
+
painter.setBrush(Qt.NoBrush)
|
|
629
|
+
painter.setPen(QPen(_with_alpha(colors.text_muted, 50)))
|
|
630
|
+
|
|
631
|
+
painter.setFont(QFont("", 11))
|
|
632
|
+
painter.drawText(btn_rect, Qt.AlignCenter, "★")
|
|
633
|
+
|
|
634
|
+
# ── Editor events (clicks on the inline buttons) ────────────
|
|
635
|
+
|
|
636
|
+
def editorEvent(self, event, model, option, index):
|
|
637
|
+
if event.type() == QEvent.MouseButtonPress:
|
|
638
|
+
project = index.data(Qt.DisplayRole)
|
|
639
|
+
if not project:
|
|
640
|
+
return super().editorEvent(event, model, option, index)
|
|
641
|
+
|
|
642
|
+
pos = event.pos()
|
|
643
|
+
|
|
644
|
+
# Click on the "Open" button
|
|
645
|
+
if self.hover_row == index.row():
|
|
646
|
+
open_rect = self._get_open_btn_rect(option.rect)
|
|
647
|
+
if open_rect.contains(pos):
|
|
648
|
+
self.open_clicked.emit(project)
|
|
649
|
+
return True
|
|
650
|
+
|
|
651
|
+
# Click on the "★" button
|
|
652
|
+
star_rect = self._get_star_btn_rect(option.rect)
|
|
653
|
+
if star_rect.contains(pos):
|
|
654
|
+
self.favorite_clicked.emit(project)
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
# Click on the checkbox
|
|
658
|
+
if self.show_checkboxes:
|
|
659
|
+
cb_rect = self.get_checkbox_rect(option.rect)
|
|
660
|
+
if cb_rect.contains(pos):
|
|
661
|
+
self.checkbox_clicked.emit(index.row(), project.id)
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
return super().editorEvent(event, model, option, index)
|
|
665
|
+
|
|
666
|
+
def mouseMoveEvent_hook(self, pos, rect: QRect):
|
|
667
|
+
"""
|
|
668
|
+
Call from the containing widget's mouseMoveEvent to refresh which
|
|
669
|
+
inline button sits under the cursor.
|
|
670
|
+
Returns True when the cursor moved to a different button (needs repaint).
|
|
671
|
+
"""
|
|
672
|
+
prev_open = self._hover_open
|
|
673
|
+
prev_star = self._hover_star
|
|
674
|
+
self._hover_open = self._get_open_btn_rect(rect).contains(pos)
|
|
675
|
+
self._hover_star = self._get_star_btn_rect(rect).contains(pos)
|
|
676
|
+
return (self._hover_open != prev_open) or (self._hover_star != prev_star)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
class ActionBar(QWidget):
|
|
680
|
+
"""
|
|
681
|
+
Action bar for the listview.
|
|
682
|
+
|
|
683
|
+
Layout:
|
|
684
|
+
[☑ multi] [filter▾] [sort▾] ──stretch── [search field▾] [🔍 input......] | [contextual actions] [🗑]
|
|
685
|
+
|
|
686
|
+
The contextual actions (edit, favourite, export, stats) only appear when an
|
|
687
|
+
item is active or selected. The delete button is always present but disabled
|
|
688
|
+
when there is no target.
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
import_clicked = Signal()
|
|
692
|
+
export_clicked = Signal()
|
|
693
|
+
edit_clicked = Signal()
|
|
694
|
+
favorite_clicked = Signal()
|
|
695
|
+
stats_clicked = Signal()
|
|
696
|
+
delete_clicked = Signal()
|
|
697
|
+
select_all_clicked = Signal()
|
|
698
|
+
toggle_multi_clicked = Signal(bool)
|
|
699
|
+
filter_changed = Signal(str)
|
|
700
|
+
sort_changed = Signal(str)
|
|
701
|
+
search_field_changed = Signal(str)
|
|
702
|
+
search_visibility_toggled = Signal()
|
|
703
|
+
search_text_changed = Signal(str)
|
|
704
|
+
|
|
705
|
+
def __init__(self, parent=None):
|
|
706
|
+
super().__init__(parent)
|
|
707
|
+
self._current_filter = "all"
|
|
708
|
+
self._current_sort = "recent"
|
|
709
|
+
self._current_search_field = "name"
|
|
710
|
+
self._setup_ui()
|
|
711
|
+
|
|
712
|
+
def _make_tool_btn(self, text: str, tooltip: str, size: int = 30) -> QToolButton:
|
|
713
|
+
btn = QToolButton()
|
|
714
|
+
btn.setText(text)
|
|
715
|
+
btn.setToolTip(tooltip)
|
|
716
|
+
btn.setFixedSize(size, size)
|
|
717
|
+
return btn
|
|
718
|
+
|
|
719
|
+
def _setup_ui(self):
|
|
720
|
+
layout = QHBoxLayout(self)
|
|
721
|
+
layout.setContentsMargins(0, 4, 0, 4)
|
|
722
|
+
layout.setSpacing(4)
|
|
723
|
+
|
|
724
|
+
# ── Left group: mode and filters ────────────────────────
|
|
725
|
+
self.multi_mode_btn = self._make_tool_btn("☑", "Multi-selection")
|
|
726
|
+
self.multi_mode_btn.setCheckable(True)
|
|
727
|
+
self.multi_mode_btn.toggled.connect(self.toggle_multi_clicked.emit)
|
|
728
|
+
layout.addWidget(self.multi_mode_btn)
|
|
729
|
+
|
|
730
|
+
self.select_all_btn = self._make_tool_btn("⊞", "Select / deselect all")
|
|
731
|
+
self.select_all_btn.setVisible(False) # multi-select only
|
|
732
|
+
self.select_all_btn.clicked.connect(self.select_all_clicked.emit)
|
|
733
|
+
layout.addWidget(self.select_all_btn)
|
|
734
|
+
|
|
735
|
+
# Filter
|
|
736
|
+
self.filter_btn = self._make_tool_btn("⊟", "Filter")
|
|
737
|
+
self.filter_btn.setPopupMode(QToolButton.InstantPopup)
|
|
738
|
+
self.filter_menu = QMenu(self.filter_btn)
|
|
739
|
+
self._filter_actions: dict[str, object] = {}
|
|
740
|
+
for key, label in [
|
|
741
|
+
("all", "All"),
|
|
742
|
+
("favorites", "Favorites"),
|
|
743
|
+
("has_description", "With description"),
|
|
744
|
+
("has_language", "With tool"),
|
|
745
|
+
("has_tags", "With tags"),
|
|
746
|
+
]:
|
|
747
|
+
action = self.filter_menu.addAction(label)
|
|
748
|
+
action.setCheckable(True)
|
|
749
|
+
action.triggered.connect(lambda _c=False, k=key: self._set_filter(k))
|
|
750
|
+
self._filter_actions[key] = action
|
|
751
|
+
self.filter_btn.setMenu(self.filter_menu)
|
|
752
|
+
layout.addWidget(self.filter_btn)
|
|
753
|
+
|
|
754
|
+
# Sort
|
|
755
|
+
self.sort_btn = self._make_tool_btn("↕", "Sort")
|
|
756
|
+
self.sort_btn.setPopupMode(QToolButton.InstantPopup)
|
|
757
|
+
self.sort_menu = QMenu(self.sort_btn)
|
|
758
|
+
self._sort_actions: dict[str, object] = {}
|
|
759
|
+
for key, label in [
|
|
760
|
+
("recent", "Most recent"),
|
|
761
|
+
("name_asc", "Name A → Z"),
|
|
762
|
+
("name_desc", "Name Z → A"),
|
|
763
|
+
("path_asc", "Path A → Z"),
|
|
764
|
+
]:
|
|
765
|
+
action = self.sort_menu.addAction(label)
|
|
766
|
+
action.setCheckable(True)
|
|
767
|
+
action.triggered.connect(lambda _c=False, k=key: self._set_sort(k))
|
|
768
|
+
self._sort_actions[key] = action
|
|
769
|
+
self.sort_btn.setMenu(self.sort_menu)
|
|
770
|
+
layout.addWidget(self.sort_btn)
|
|
771
|
+
|
|
772
|
+
layout.addStretch(1)
|
|
773
|
+
|
|
774
|
+
# ── Centre: selection label (only shown when something is selected) ──
|
|
775
|
+
self.selection_label = QLabel("")
|
|
776
|
+
self.selection_label.setVisible(False)
|
|
777
|
+
layout.addWidget(self.selection_label)
|
|
778
|
+
|
|
779
|
+
layout.addStretch(1)
|
|
780
|
+
|
|
781
|
+
# ── Right group: search + contextual actions ────────────
|
|
782
|
+
# Search field
|
|
783
|
+
self.search_mode_btn = self._make_tool_btn("🔍", "Search field")
|
|
784
|
+
self.search_mode_btn.setPopupMode(QToolButton.InstantPopup)
|
|
785
|
+
self.search_mode_menu = QMenu(self.search_mode_btn)
|
|
786
|
+
self._search_field_actions: dict[str, object] = {}
|
|
787
|
+
for key, label in [
|
|
788
|
+
("name", "Name"),
|
|
789
|
+
("tags", "Tags"),
|
|
790
|
+
("description", "Description"),
|
|
791
|
+
("path", "Path"),
|
|
792
|
+
("id", "ID"),
|
|
793
|
+
("all", "Everything"),
|
|
794
|
+
]:
|
|
795
|
+
action = self.search_mode_menu.addAction(label)
|
|
796
|
+
action.setCheckable(True)
|
|
797
|
+
action.triggered.connect(lambda _c=False, k=key: self._set_search_field(k))
|
|
798
|
+
self._search_field_actions[key] = action
|
|
799
|
+
self.search_mode_btn.setMenu(self.search_mode_menu)
|
|
800
|
+
layout.addWidget(self.search_mode_btn)
|
|
801
|
+
|
|
802
|
+
self.search_input = QLineEdit()
|
|
803
|
+
self.search_input.setPlaceholderText("Search...")
|
|
804
|
+
self.search_input.setMinimumHeight(30)
|
|
805
|
+
self.search_input.setMinimumWidth(160)
|
|
806
|
+
self.search_input.textChanged.connect(self.search_text_changed.emit)
|
|
807
|
+
layout.addWidget(self.search_input)
|
|
808
|
+
|
|
809
|
+
# Visual separator
|
|
810
|
+
sep = QLabel("|")
|
|
811
|
+
sep.setFixedWidth(10)
|
|
812
|
+
sep.setAlignment(Qt.AlignCenter)
|
|
813
|
+
layout.addWidget(sep)
|
|
814
|
+
self._separator = sep
|
|
815
|
+
|
|
816
|
+
# Contextual actions (only visible when there is a target)
|
|
817
|
+
self.edit_btn = self._make_tool_btn("✏", "Edit")
|
|
818
|
+
self.edit_btn.clicked.connect(self.edit_clicked.emit)
|
|
819
|
+
layout.addWidget(self.edit_btn)
|
|
820
|
+
|
|
821
|
+
self.favorite_btn = self._make_tool_btn("★", "Mark / unmark favourite")
|
|
822
|
+
self.favorite_btn.clicked.connect(self.favorite_clicked.emit)
|
|
823
|
+
layout.addWidget(self.favorite_btn)
|
|
824
|
+
|
|
825
|
+
self.export_btn = self._make_tool_btn("↑", "Export")
|
|
826
|
+
self.export_btn.clicked.connect(self.export_clicked.emit)
|
|
827
|
+
layout.addWidget(self.export_btn)
|
|
828
|
+
|
|
829
|
+
self.import_btn = self._make_tool_btn("↓", "Import")
|
|
830
|
+
self.import_btn.clicked.connect(self.import_clicked.emit)
|
|
831
|
+
layout.addWidget(self.import_btn)
|
|
832
|
+
|
|
833
|
+
self.stats_btn = self._make_tool_btn("◫", "Statistics")
|
|
834
|
+
self.stats_btn.clicked.connect(self.stats_clicked.emit)
|
|
835
|
+
layout.addWidget(self.stats_btn)
|
|
836
|
+
|
|
837
|
+
self.delete_btn = self._make_tool_btn("⌫", "Delete selected")
|
|
838
|
+
self.delete_btn.clicked.connect(self.delete_clicked.emit)
|
|
839
|
+
layout.addWidget(self.delete_btn)
|
|
840
|
+
|
|
841
|
+
self._context_buttons = [
|
|
842
|
+
self.edit_btn, self.favorite_btn, self.export_btn,
|
|
843
|
+
self.import_btn, self.stats_btn,
|
|
844
|
+
]
|
|
845
|
+
|
|
846
|
+
self._update_filter_checks()
|
|
847
|
+
self._update_sort_checks()
|
|
848
|
+
self._update_search_field_checks()
|
|
849
|
+
# Start with context buttons hidden
|
|
850
|
+
self.set_actions_enabled(False)
|
|
851
|
+
|
|
852
|
+
# ── State refresh ────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
def _update_filter_checks(self):
|
|
855
|
+
for key, action in self._filter_actions.items():
|
|
856
|
+
action.setChecked(key == self._current_filter)
|
|
857
|
+
|
|
858
|
+
def _update_sort_checks(self):
|
|
859
|
+
for key, action in self._sort_actions.items():
|
|
860
|
+
action.setChecked(key == self._current_sort)
|
|
861
|
+
|
|
862
|
+
def _update_search_field_checks(self):
|
|
863
|
+
icon_map = {"name": "🔍", "tags": "🏷", "description": "≡",
|
|
864
|
+
"path": "⌂", "id": "#", "all": "✦"}
|
|
865
|
+
self.search_mode_btn.setText(icon_map.get(self._current_search_field, "🔍"))
|
|
866
|
+
for key, action in self._search_field_actions.items():
|
|
867
|
+
action.setChecked(key == self._current_search_field)
|
|
868
|
+
|
|
869
|
+
def _set_filter(self, key: str):
|
|
870
|
+
self._current_filter = key
|
|
871
|
+
self._update_filter_checks()
|
|
872
|
+
self.filter_changed.emit(key)
|
|
873
|
+
|
|
874
|
+
def _set_sort(self, key: str):
|
|
875
|
+
self._current_sort = key
|
|
876
|
+
self._update_sort_checks()
|
|
877
|
+
self.sort_changed.emit(key)
|
|
878
|
+
|
|
879
|
+
def _set_search_field(self, key: str):
|
|
880
|
+
self._current_search_field = key
|
|
881
|
+
self._update_search_field_checks()
|
|
882
|
+
self.search_field_changed.emit(key)
|
|
883
|
+
|
|
884
|
+
# ── Public API ──────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
def set_search_visible(self, visible: bool):
|
|
887
|
+
self.search_input.setVisible(visible)
|
|
888
|
+
self.search_mode_btn.setVisible(visible)
|
|
889
|
+
if visible:
|
|
890
|
+
self.search_input.setFocus()
|
|
891
|
+
else:
|
|
892
|
+
self.search_input.clear()
|
|
893
|
+
|
|
894
|
+
def set_selection_count(self, count: int):
|
|
895
|
+
if count > 0:
|
|
896
|
+
self.selection_label.setText(f"{count} seleccionado{'s' if count != 1 else ''}")
|
|
897
|
+
self.selection_label.setVisible(True)
|
|
898
|
+
else:
|
|
899
|
+
self.selection_label.setVisible(False)
|
|
900
|
+
|
|
901
|
+
def set_actions_enabled(self, has_target: bool):
|
|
902
|
+
"""Show/hide the contextual buttons and separator depending on the target."""
|
|
903
|
+
for btn in self._context_buttons:
|
|
904
|
+
btn.setVisible(has_target)
|
|
905
|
+
self._separator.setVisible(has_target)
|
|
906
|
+
self.delete_btn.setEnabled(has_target)
|
|
907
|
+
|
|
908
|
+
def set_multi_mode(self, enabled: bool):
|
|
909
|
+
was_blocked = self.multi_mode_btn.blockSignals(True)
|
|
910
|
+
self.multi_mode_btn.setChecked(enabled)
|
|
911
|
+
self.multi_mode_btn.blockSignals(was_blocked)
|
|
912
|
+
self.select_all_btn.setVisible(enabled)
|
|
913
|
+
|
|
914
|
+
def set_select_all_state(self, all_selected: bool):
|
|
915
|
+
self.select_all_btn.setText("☐" if all_selected else "⊞")
|
|
916
|
+
|
|
917
|
+
def get_search_text(self) -> str:
|
|
918
|
+
return self.search_input.text().strip()
|
|
919
|
+
|
|
920
|
+
def get_search_field(self) -> str:
|
|
921
|
+
return self._current_search_field
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
class ProjectListWidget(QWidget):
|
|
925
|
+
"""
|
|
926
|
+
Gmail-style project list with multi-selection.
|
|
927
|
+
|
|
928
|
+
Agnostic: takes an optional external delegate_class. When none is passed it
|
|
929
|
+
uses ProjectItemDelegate. The delegate must subclass ProjectItemDelegate or
|
|
930
|
+
implement the same interface (checkbox_clicked, open_clicked and
|
|
931
|
+
favorite_clicked signals; set_hover_row, set_hover_checkbox,
|
|
932
|
+
set_show_checkboxes, get_checkbox_rect methods).
|
|
933
|
+
|
|
934
|
+
Public signals:
|
|
935
|
+
project_selected - active item changed (single click)
|
|
936
|
+
project_double_clicked - item double-clicked
|
|
937
|
+
open_requested - inline "Open" button clicked
|
|
938
|
+
favorite_toggled - inline "★" button clicked
|
|
939
|
+
selection_changed - the selected/active item list changed
|
|
940
|
+
import_requested - import action from the action bar
|
|
941
|
+
export_requested - export action
|
|
942
|
+
favorite_requested - favourite action from the action bar (bulk)
|
|
943
|
+
delete_requested - delete action
|
|
944
|
+
edit_requested - edit action
|
|
945
|
+
stats_requested - statistics action
|
|
946
|
+
page_changed - page changed
|
|
947
|
+
"""
|
|
948
|
+
|
|
949
|
+
project_selected = Signal(object) # ProjectItem
|
|
950
|
+
project_double_clicked = Signal(object) # ProjectItem
|
|
951
|
+
open_requested = Signal(object) # ProjectItem ← inline Open button
|
|
952
|
+
favorite_toggled = Signal(object) # ProjectItem ← inline ★ button
|
|
953
|
+
selection_changed = Signal(list)
|
|
954
|
+
import_requested = Signal(list)
|
|
955
|
+
export_requested = Signal(list)
|
|
956
|
+
favorite_requested = Signal(list)
|
|
957
|
+
delete_requested = Signal(list)
|
|
958
|
+
edit_requested = Signal(list)
|
|
959
|
+
stats_requested = Signal(list)
|
|
960
|
+
page_changed = Signal(int)
|
|
961
|
+
|
|
962
|
+
def __init__(self,
|
|
963
|
+
data_loader: Callable[[int, int], List[ProjectItem]],
|
|
964
|
+
total_count_loader: Callable[[], int],
|
|
965
|
+
items_per_page: int = 10,
|
|
966
|
+
show_pagination: bool = True,
|
|
967
|
+
sort_changed_callback: Optional[Callable[[str], None]] = None,
|
|
968
|
+
filter_changed_callback: Optional[Callable[[str], None]] = None,
|
|
969
|
+
search_changed_callback: Optional[Callable[[str, str], None]] = None,
|
|
970
|
+
delegate_class: Optional[Type[ProjectItemDelegate]] = None,
|
|
971
|
+
parent=None):
|
|
972
|
+
super().__init__(parent)
|
|
973
|
+
self.data_loader = data_loader
|
|
974
|
+
self.total_count_loader = total_count_loader
|
|
975
|
+
self.items_per_page = items_per_page
|
|
976
|
+
self.show_pagination = show_pagination
|
|
977
|
+
self.sort_changed_callback = sort_changed_callback
|
|
978
|
+
self.filter_changed_callback = filter_changed_callback
|
|
979
|
+
self.search_changed_callback = search_changed_callback
|
|
980
|
+
self._delegate_class = delegate_class or ProjectItemDelegate
|
|
981
|
+
self._current_filter = "all"
|
|
982
|
+
self._current_sort = "recent"
|
|
983
|
+
self._current_search_field = "name"
|
|
984
|
+
self._loaded_projects: List[ProjectItem] = []
|
|
985
|
+
self._multi_select_enabled = False
|
|
986
|
+
self._setup_ui()
|
|
987
|
+
|
|
988
|
+
def _setup_ui(self):
|
|
989
|
+
layout = QVBoxLayout(self)
|
|
990
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
991
|
+
layout.setSpacing(8)
|
|
992
|
+
|
|
993
|
+
self.action_bar = ActionBar()
|
|
994
|
+
self.action_bar.import_clicked.connect(
|
|
995
|
+
lambda: self.import_requested.emit(self._get_action_projects()))
|
|
996
|
+
self.action_bar.export_clicked.connect(
|
|
997
|
+
lambda: self.export_requested.emit(self._get_action_projects()))
|
|
998
|
+
self.action_bar.edit_clicked.connect(
|
|
999
|
+
lambda: self.edit_requested.emit(self._get_action_projects()))
|
|
1000
|
+
self.action_bar.favorite_clicked.connect(
|
|
1001
|
+
lambda: self.favorite_requested.emit(self._get_action_projects()))
|
|
1002
|
+
self.action_bar.stats_clicked.connect(
|
|
1003
|
+
lambda: self.stats_requested.emit(self._get_action_projects()))
|
|
1004
|
+
self.action_bar.delete_clicked.connect(
|
|
1005
|
+
lambda: self.delete_requested.emit(self._get_action_projects()))
|
|
1006
|
+
self.action_bar.select_all_clicked.connect(self.select_all)
|
|
1007
|
+
self.action_bar.toggle_multi_clicked.connect(self._set_multi_select_mode)
|
|
1008
|
+
self.action_bar.filter_changed.connect(self._set_filter)
|
|
1009
|
+
self.action_bar.sort_changed.connect(self._set_sort)
|
|
1010
|
+
self.action_bar.search_field_changed.connect(self._set_search_field)
|
|
1011
|
+
self.action_bar.search_text_changed.connect(self._on_search_text_changed)
|
|
1012
|
+
self.action_bar.search_visibility_toggled.connect(self._toggle_search)
|
|
1013
|
+
layout.addWidget(self.action_bar)
|
|
1014
|
+
|
|
1015
|
+
# ── QListView ────────────────────────────────────────────
|
|
1016
|
+
self.list_view = QListView()
|
|
1017
|
+
self.list_view.setSelectionMode(QAbstractItemView.NoSelection)
|
|
1018
|
+
self.list_view.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
1019
|
+
self.list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
1020
|
+
self.list_view.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
|
|
1021
|
+
self.list_view.setMouseTracking(True)
|
|
1022
|
+
|
|
1023
|
+
self.model = ProjectListModel()
|
|
1024
|
+
self.delegate = self._delegate_class()
|
|
1025
|
+
self.delegate.set_show_checkboxes(self._multi_select_enabled)
|
|
1026
|
+
|
|
1027
|
+
self.list_view.setModel(self.model)
|
|
1028
|
+
self.list_view.setItemDelegate(self.delegate)
|
|
1029
|
+
|
|
1030
|
+
self.list_view.clicked.connect(self._on_clicked)
|
|
1031
|
+
self.list_view.doubleClicked.connect(self._on_double_clicked)
|
|
1032
|
+
self.list_view.entered.connect(self._on_entered)
|
|
1033
|
+
self.delegate.checkbox_clicked.connect(self._on_checkbox)
|
|
1034
|
+
|
|
1035
|
+
# ── Inline button signals ────────────────────────────────
|
|
1036
|
+
if hasattr(self.delegate, "open_clicked"):
|
|
1037
|
+
self.delegate.open_clicked.connect(self._on_inline_open)
|
|
1038
|
+
if hasattr(self.delegate, "favorite_clicked"):
|
|
1039
|
+
self.delegate.favorite_clicked.connect(self._on_inline_favorite)
|
|
1040
|
+
|
|
1041
|
+
layout.addWidget(self.list_view)
|
|
1042
|
+
self._apply_list_surface_style()
|
|
1043
|
+
|
|
1044
|
+
# ── Pagination ───────────────────────────────────────────
|
|
1045
|
+
if self.show_pagination:
|
|
1046
|
+
self.pagination = PaginationControl(
|
|
1047
|
+
total_items=0,
|
|
1048
|
+
items_per_page=self.items_per_page,
|
|
1049
|
+
items_per_page_options=[5, 10, 15, 20, 25],
|
|
1050
|
+
)
|
|
1051
|
+
self.pagination.page_changed.connect(self._on_page_changed)
|
|
1052
|
+
self.pagination.items_per_page_changed.connect(lambda _: self.load_data())
|
|
1053
|
+
layout.addWidget(self.pagination)
|
|
1054
|
+
|
|
1055
|
+
self._update_action_bar()
|
|
1056
|
+
|
|
1057
|
+
# ── Data loading ───────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
def load_data(self):
|
|
1060
|
+
page = self.pagination.get_current_page() if self.show_pagination else 1
|
|
1061
|
+
size = self.pagination.get_items_per_page() if self.show_pagination \
|
|
1062
|
+
else self.items_per_page
|
|
1063
|
+
|
|
1064
|
+
projects = self.data_loader(page, size)
|
|
1065
|
+
total = self.total_count_loader()
|
|
1066
|
+
self._loaded_projects = projects
|
|
1067
|
+
|
|
1068
|
+
if self.show_pagination:
|
|
1069
|
+
self.pagination.set_total_items(total)
|
|
1070
|
+
|
|
1071
|
+
self._apply_view_controls()
|
|
1072
|
+
|
|
1073
|
+
def refresh(self):
|
|
1074
|
+
self.load_data()
|
|
1075
|
+
|
|
1076
|
+
# ── Forced repaint after a theme change ─────────────────────
|
|
1077
|
+
|
|
1078
|
+
def force_repaint(self):
|
|
1079
|
+
"""
|
|
1080
|
+
Call this after switching theme.
|
|
1081
|
+
unpolish/polish forces Qt to re-read QPalette on ALL widgets,
|
|
1082
|
+
including the QListView and its viewport.
|
|
1083
|
+
"""
|
|
1084
|
+
# In QAbstractItemView, the visible repaint happens in the viewport.
|
|
1085
|
+
# Calling update() on a QListView can resolve to an incompatible overload.
|
|
1086
|
+
for widget in [self, self.list_view.viewport()]:
|
|
1087
|
+
widget.style().unpolish(widget)
|
|
1088
|
+
widget.style().polish(widget)
|
|
1089
|
+
widget.update()
|
|
1090
|
+
self._apply_list_surface_style()
|
|
1091
|
+
|
|
1092
|
+
# Force a repaint of the delegate
|
|
1093
|
+
self.list_view.viewport().update()
|
|
1094
|
+
|
|
1095
|
+
def changeEvent(self, event):
|
|
1096
|
+
super().changeEvent(event)
|
|
1097
|
+
if event.type() in (QEvent.PaletteChange, QEvent.StyleChange):
|
|
1098
|
+
self._apply_list_surface_style()
|
|
1099
|
+
self.list_view.viewport().update()
|
|
1100
|
+
|
|
1101
|
+
def _apply_list_surface_style(self):
|
|
1102
|
+
colors = ThemeColors.get()
|
|
1103
|
+
self.list_view.setStyleSheet(
|
|
1104
|
+
f"QListView {{ background-color: {colors.bg_base.name()}; }}"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# ── Slots internos ───────────────────────────────────────────
|
|
1108
|
+
|
|
1109
|
+
def _on_checkbox(self, row: int, project_id):
|
|
1110
|
+
if not self._multi_select_enabled:
|
|
1111
|
+
return
|
|
1112
|
+
self.model.toggle_selection(project_id)
|
|
1113
|
+
self._update_action_bar()
|
|
1114
|
+
self._emit_selection_changed()
|
|
1115
|
+
|
|
1116
|
+
def _on_clicked(self, index):
|
|
1117
|
+
project = self.model.get_project(index.row())
|
|
1118
|
+
if project:
|
|
1119
|
+
self.model.set_active_project(project.id)
|
|
1120
|
+
self.project_selected.emit(project)
|
|
1121
|
+
self._update_action_bar()
|
|
1122
|
+
self._emit_selection_changed()
|
|
1123
|
+
|
|
1124
|
+
def _on_double_clicked(self, index):
|
|
1125
|
+
project = self.model.get_project(index.row())
|
|
1126
|
+
if project:
|
|
1127
|
+
self.project_double_clicked.emit(project)
|
|
1128
|
+
|
|
1129
|
+
def _on_entered(self, index):
|
|
1130
|
+
"""Hover - refresh hover_row and hover_checkbox on the delegate."""
|
|
1131
|
+
self.delegate.set_hover_row(index.row())
|
|
1132
|
+
|
|
1133
|
+
pos = self.list_view.viewport().mapFromGlobal(QCursor.pos())
|
|
1134
|
+
item_rect = self.list_view.visualRect(index)
|
|
1135
|
+
cb_rect = self.delegate.get_checkbox_rect(item_rect)
|
|
1136
|
+
|
|
1137
|
+
self.delegate.set_hover_checkbox(
|
|
1138
|
+
index.row() if (not cb_rect.isNull() and cb_rect.contains(pos)) else -1
|
|
1139
|
+
)
|
|
1140
|
+
self.list_view.viewport().update()
|
|
1141
|
+
|
|
1142
|
+
def _on_inline_open(self, project: ProjectItem):
|
|
1143
|
+
"""Inline 'Open' button clicked - emits open_requested and project_double_clicked."""
|
|
1144
|
+
self.open_requested.emit(project)
|
|
1145
|
+
self.project_double_clicked.emit(project)
|
|
1146
|
+
|
|
1147
|
+
def _on_inline_favorite(self, project: ProjectItem):
|
|
1148
|
+
"""Inline '★' button clicked - emits favorite_toggled and favorite_requested."""
|
|
1149
|
+
self.favorite_toggled.emit(project)
|
|
1150
|
+
self.favorite_requested.emit([project])
|
|
1151
|
+
|
|
1152
|
+
def _on_page_changed(self, _page: int):
|
|
1153
|
+
self.load_data()
|
|
1154
|
+
self.page_changed.emit(_page)
|
|
1155
|
+
|
|
1156
|
+
def _update_action_bar(self):
|
|
1157
|
+
selected_count = self.model.get_selected_count()
|
|
1158
|
+
targets = self._get_action_projects()
|
|
1159
|
+
self.action_bar.set_selection_count(selected_count if self._multi_select_enabled else len(targets))
|
|
1160
|
+
self.action_bar.set_actions_enabled(bool(targets))
|
|
1161
|
+
self.action_bar.set_multi_mode(self._multi_select_enabled)
|
|
1162
|
+
self.action_bar.set_select_all_state(self.model.are_all_selected())
|
|
1163
|
+
|
|
1164
|
+
def _get_action_projects(self) -> List[ProjectItem]:
|
|
1165
|
+
if self._multi_select_enabled:
|
|
1166
|
+
selected = self.model.get_selected_projects()
|
|
1167
|
+
if selected:
|
|
1168
|
+
return selected
|
|
1169
|
+
active = self.model.get_active_project()
|
|
1170
|
+
return [active] if active else []
|
|
1171
|
+
|
|
1172
|
+
def _emit_selection_changed(self):
|
|
1173
|
+
self.selection_changed.emit(self._get_action_projects())
|
|
1174
|
+
|
|
1175
|
+
def _set_multi_select_mode(self, enabled: bool):
|
|
1176
|
+
self._multi_select_enabled = enabled
|
|
1177
|
+
self.delegate.set_show_checkboxes(enabled)
|
|
1178
|
+
if not enabled:
|
|
1179
|
+
self.model.clear_selection()
|
|
1180
|
+
self.list_view.viewport().update()
|
|
1181
|
+
self._update_action_bar()
|
|
1182
|
+
self._emit_selection_changed()
|
|
1183
|
+
|
|
1184
|
+
def _set_filter(self, key: str):
|
|
1185
|
+
self._current_filter = key
|
|
1186
|
+
if self.filter_changed_callback is not None:
|
|
1187
|
+
self.filter_changed_callback(key)
|
|
1188
|
+
self.load_data()
|
|
1189
|
+
else:
|
|
1190
|
+
self._apply_view_controls()
|
|
1191
|
+
|
|
1192
|
+
def _set_sort(self, key: str):
|
|
1193
|
+
self._current_sort = key
|
|
1194
|
+
if self.sort_changed_callback is not None:
|
|
1195
|
+
self.sort_changed_callback(key)
|
|
1196
|
+
self.load_data()
|
|
1197
|
+
else:
|
|
1198
|
+
self._apply_view_controls()
|
|
1199
|
+
|
|
1200
|
+
def _set_search_field(self, key: str):
|
|
1201
|
+
self._current_search_field = key
|
|
1202
|
+
if self.search_changed_callback is not None:
|
|
1203
|
+
self.search_changed_callback(self._current_search_field, self.action_bar.get_search_text())
|
|
1204
|
+
self.load_data()
|
|
1205
|
+
else:
|
|
1206
|
+
self._apply_view_controls()
|
|
1207
|
+
|
|
1208
|
+
def _on_search_text_changed(self, text: str):
|
|
1209
|
+
if self.search_changed_callback is not None:
|
|
1210
|
+
self.search_changed_callback(self._current_search_field, text)
|
|
1211
|
+
self.load_data()
|
|
1212
|
+
else:
|
|
1213
|
+
self._apply_view_controls()
|
|
1214
|
+
|
|
1215
|
+
def _toggle_search(self):
|
|
1216
|
+
visible = self.action_bar.search_input.isHidden()
|
|
1217
|
+
self.action_bar.set_search_visible(visible)
|
|
1218
|
+
if self.search_changed_callback is not None:
|
|
1219
|
+
self.search_changed_callback(self._current_search_field, self.action_bar.get_search_text())
|
|
1220
|
+
self.load_data()
|
|
1221
|
+
else:
|
|
1222
|
+
self._apply_view_controls()
|
|
1223
|
+
|
|
1224
|
+
def _matches_filter(self, project: ProjectItem) -> bool:
|
|
1225
|
+
if self._current_filter == "all":
|
|
1226
|
+
return True
|
|
1227
|
+
if self._current_filter == "has_description":
|
|
1228
|
+
return bool((project.description or "").strip())
|
|
1229
|
+
if self._current_filter == "has_language":
|
|
1230
|
+
return bool(project.language)
|
|
1231
|
+
if self._current_filter == "has_tags":
|
|
1232
|
+
return bool(project.tags)
|
|
1233
|
+
if self._current_filter == "favorites":
|
|
1234
|
+
return bool(project.favorite)
|
|
1235
|
+
return True
|
|
1236
|
+
|
|
1237
|
+
def _apply_view_controls(self):
|
|
1238
|
+
projects = self._loaded_projects
|
|
1239
|
+
|
|
1240
|
+
if self.filter_changed_callback is None:
|
|
1241
|
+
projects = [p for p in projects if self._matches_filter(p)]
|
|
1242
|
+
|
|
1243
|
+
if self.search_changed_callback is None:
|
|
1244
|
+
query = self.action_bar.get_search_text().lower()
|
|
1245
|
+
if query:
|
|
1246
|
+
field = self._current_search_field
|
|
1247
|
+
|
|
1248
|
+
def matches(project: ProjectItem) -> bool:
|
|
1249
|
+
haystack = {
|
|
1250
|
+
"name": (project.name or "").lower(),
|
|
1251
|
+
"description": (project.description or "").lower(),
|
|
1252
|
+
"path": (project.path or "").lower(),
|
|
1253
|
+
"tags": " ".join(project.tags).lower(),
|
|
1254
|
+
"id": str(project.id).lower(),
|
|
1255
|
+
"all": " ".join([
|
|
1256
|
+
(project.name or "").lower(),
|
|
1257
|
+
(project.description or "").lower(),
|
|
1258
|
+
(project.path or "").lower(),
|
|
1259
|
+
" ".join(project.tags).lower(),
|
|
1260
|
+
str(project.id).lower(),
|
|
1261
|
+
]),
|
|
1262
|
+
}
|
|
1263
|
+
return query in haystack.get(field, haystack["all"])
|
|
1264
|
+
|
|
1265
|
+
projects = [p for p in projects if matches(p)]
|
|
1266
|
+
|
|
1267
|
+
self.model.set_projects(projects)
|
|
1268
|
+
self._update_action_bar()
|
|
1269
|
+
self._emit_selection_changed()
|
|
1270
|
+
|
|
1271
|
+
# ── Public API ──────────────────────────────────────────────
|
|
1272
|
+
|
|
1273
|
+
def select_all(self):
|
|
1274
|
+
if not self._multi_select_enabled:
|
|
1275
|
+
self._set_multi_select_mode(True)
|
|
1276
|
+
if self.model.are_all_selected():
|
|
1277
|
+
self.model.clear_selection()
|
|
1278
|
+
else:
|
|
1279
|
+
self.model.select_all()
|
|
1280
|
+
self._update_action_bar()
|
|
1281
|
+
self._emit_selection_changed()
|
|
1282
|
+
|
|
1283
|
+
def clear_selection(self):
|
|
1284
|
+
self.model.clear_selection()
|
|
1285
|
+
self.model.set_active_project(None)
|
|
1286
|
+
self._update_action_bar()
|
|
1287
|
+
self._emit_selection_changed()
|