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.
Files changed (84) hide show
  1. ms_components/__init__.py +0 -0
  2. ms_components/base.qss +252 -0
  3. ms_components/listview.py +1287 -0
  4. ms_components/ms_dockwidget/__init__.py +0 -0
  5. ms_components/ms_dockwidget/resources/__init__.py +1 -0
  6. ms_components/ms_dockwidget/resources/icons/__init__.py +10 -0
  7. ms_components/ms_dockwidget/resources/icons/folder.svg +5 -0
  8. ms_components/ms_dockwidget/widget.py +1975 -0
  9. ms_components/ms_monitor/__init__.py +43 -0
  10. ms_components/ms_monitor/bridge.py +400 -0
  11. ms_components/ms_monitor/cards.py +75 -0
  12. ms_components/ms_monitor/chunk_panel.py +85 -0
  13. ms_components/ms_monitor/config.py +20 -0
  14. ms_components/ms_monitor/events_panel.py +178 -0
  15. ms_components/ms_monitor/executor_panel.py +149 -0
  16. ms_components/ms_monitor/executors_tab.py +355 -0
  17. ms_components/ms_monitor/formatting.py +51 -0
  18. ms_components/ms_monitor/job_cards.py +536 -0
  19. ms_components/ms_monitor/job_filter_proxy.py +48 -0
  20. ms_components/ms_monitor/job_tree_model.py +106 -0
  21. ms_components/ms_monitor/models.py +165 -0
  22. ms_components/ms_monitor/palette.py +41 -0
  23. ms_components/ms_monitor/polling.py +498 -0
  24. ms_components/ms_monitor/widget.py +401 -0
  25. ms_components/ms_projects/__init__.py +4 -0
  26. ms_components/ms_projects/app.py +18 -0
  27. ms_components/ms_projects/browser.py +2132 -0
  28. ms_components/ms_projects/scratch.py +772 -0
  29. ms_components/ms_projects/widget.py +284 -0
  30. ms_components/ms_pymol/__init__.py +13 -0
  31. ms_components/ms_pymol/icons/actions.svg +1 -0
  32. ms_components/ms_pymol/icons/box.svg +1 -0
  33. ms_components/ms_pymol/icons/center.svg +1 -0
  34. ms_components/ms_pymol/icons/display.svg +4 -0
  35. ms_components/ms_pymol/icons/hide.svg +1 -0
  36. ms_components/ms_pymol/icons/menu.svg +5 -0
  37. ms_components/ms_pymol/icons/orient.svg +11 -0
  38. ms_components/ms_pymol/icons/presets.svg +7 -0
  39. ms_components/ms_pymol/icons/rock.svg +6 -0
  40. ms_components/ms_pymol/icons/selection.svg +11 -0
  41. ms_components/ms_pymol/icons/settings.svg +4 -0
  42. ms_components/ms_pymol/icons/zoom.svg +6 -0
  43. ms_components/ms_pymol/plugins.py +158 -0
  44. ms_components/ms_pymol/widget.py +700 -0
  45. ms_components/ms_settings/__init__.py +3 -0
  46. ms_components/ms_settings/panel.py +899 -0
  47. ms_components/ms_settings/workers_editor.py +335 -0
  48. ms_components/ms_stepper.py +1106 -0
  49. ms_components/ms_table/__init__.py +67 -0
  50. ms_components/ms_table/action_header.py +502 -0
  51. ms_components/ms_table/cell_delegate.py +278 -0
  52. ms_components/ms_table/column_panel.py +120 -0
  53. ms_components/ms_table/example_usage.py +376 -0
  54. ms_components/ms_table/filter_panel.py +326 -0
  55. ms_components/ms_table/icons/columns.svg +5 -0
  56. ms_components/ms_table/icons/export.svg +6 -0
  57. ms_components/ms_table/icons/filter.svg +1 -0
  58. ms_components/ms_table/icons/menu.svg +5 -0
  59. ms_components/ms_table/icons/reload.svg +4 -0
  60. ms_components/ms_table/icons/search.svg +1 -0
  61. ms_components/ms_table/icons/settings.svg +4 -0
  62. ms_components/ms_table/icons/sort-asc.svg +1 -0
  63. ms_components/ms_table/icons/sort-desc.svg +1 -0
  64. ms_components/ms_table/icons/sort.svg +1 -0
  65. ms_components/ms_table/metric_filter_bar.py +232 -0
  66. ms_components/ms_table/pagination_control.py +178 -0
  67. ms_components/ms_table/query_builder.py +545 -0
  68. ms_components/ms_table/session_provider.py +11 -0
  69. ms_components/ms_table/smart_table_view.py +1378 -0
  70. ms_components/ms_table/sort_panel.py +171 -0
  71. ms_components/ms_table/table_config.py +394 -0
  72. ms_components/ms_table/table_model.py +329 -0
  73. ms_components/ms_table/test_external_filters.py +66 -0
  74. ms_components/ms_table/test_toolbar.py +130 -0
  75. ms_components/pagination.py +221 -0
  76. ms_components/step_dialog.py +135 -0
  77. ms_components/theme.py +346 -0
  78. ms_components/tool_panel.py +480 -0
  79. ms_components/wheel.py +66 -0
  80. ms_components-0.1.0.dist-info/METADATA +47 -0
  81. ms_components-0.1.0.dist-info/RECORD +84 -0
  82. ms_components-0.1.0.dist-info/WHEEL +5 -0
  83. ms_components-0.1.0.dist-info/licenses/LICENSE +21 -0
  84. 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()