1
0

pattern_model.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """Pattern list model backed by the firmware's ``/sand_patterns`` route.
  2. Patterns now live on the table's SD card, not the local filesystem. This model
  3. fetches the catalogue over HTTP and renders each ``.thr`` preview locally
  4. (cached to disk), updating rows as previews become available.
  5. """
  6. import asyncio
  7. import logging
  8. from PySide6.QtCore import QAbstractListModel, Qt, Slot, QModelIndex
  9. from PySide6.QtQml import QmlElement
  10. from firmware_client import FirmwareClient
  11. import thr_preview
  12. QML_IMPORT_NAME = "DuneWeaver"
  13. QML_IMPORT_MAJOR_VERSION = 1
  14. logger = logging.getLogger("DuneWeaver.PatternModel")
  15. @QmlElement
  16. class PatternModel(QAbstractListModel):
  17. """Model for the pattern grid, sourced from the sand table over HTTP."""
  18. NameRole = Qt.UserRole + 1
  19. PathRole = Qt.UserRole + 2
  20. PreviewRole = Qt.UserRole + 3
  21. def __init__(self):
  22. super().__init__()
  23. self._patterns = [] # all patterns [{name, path}]
  24. self._filtered_patterns = [] # current view
  25. self._search_text = ""
  26. self._previews = {} # rel_path -> cached png path ("" = pending)
  27. self._rendering = set() # rel_paths with an in-flight render
  28. self._client = FirmwareClient.instance()
  29. self._client.baseUrlChanged.connect(self._on_table_changed)
  30. self.refresh()
  31. def roleNames(self):
  32. return {
  33. self.NameRole: b"name",
  34. self.PathRole: b"path",
  35. self.PreviewRole: b"preview",
  36. }
  37. def rowCount(self, parent=QModelIndex()):
  38. return len(self._filtered_patterns)
  39. def data(self, index, role):
  40. if not index.isValid() or index.row() >= len(self._filtered_patterns):
  41. return None
  42. pattern = self._filtered_patterns[index.row()]
  43. if role == self.NameRole:
  44. return pattern["name"]
  45. elif role == self.PathRole:
  46. return pattern["path"]
  47. elif role == self.PreviewRole:
  48. return self._preview_for(pattern["name"])
  49. return None
  50. # ------------------------------------------------------------- previews
  51. def _preview_for(self, rel_path):
  52. """Return a cached preview path, kicking off a render if needed."""
  53. cached = self._previews.get(rel_path)
  54. if cached is not None:
  55. return cached
  56. # Fast synchronous cache lookup on disk.
  57. on_disk = thr_preview.cached_preview(self._client.base_url, rel_path)
  58. if on_disk:
  59. self._previews[rel_path] = on_disk
  60. return on_disk
  61. # Not cached yet - render asynchronously and update the row later.
  62. self._schedule_render(rel_path)
  63. return ""
  64. def _schedule_render(self, rel_path):
  65. if rel_path in self._rendering or not self._client.base_url:
  66. return
  67. self._rendering.add(rel_path)
  68. try:
  69. asyncio.get_event_loop().create_task(self._render(rel_path))
  70. except RuntimeError:
  71. self._rendering.discard(rel_path)
  72. async def _render(self, rel_path):
  73. base_url = self._client.base_url
  74. try:
  75. path = await thr_preview.render_preview(self._client, base_url, rel_path)
  76. finally:
  77. self._rendering.discard(rel_path)
  78. if base_url != self._client.base_url:
  79. return # table changed under us; drop stale result
  80. self._previews[rel_path] = path
  81. self._emit_preview_changed(rel_path)
  82. def _emit_preview_changed(self, rel_path):
  83. for row, pattern in enumerate(self._filtered_patterns):
  84. if pattern["name"] == rel_path:
  85. idx = self.index(row, 0)
  86. self.dataChanged.emit(idx, idx, [self.PreviewRole])
  87. break
  88. # -------------------------------------------------------------- fetching
  89. def _on_table_changed(self, _base_url):
  90. self._previews.clear()
  91. self._rendering.clear()
  92. self.refresh()
  93. @Slot()
  94. def refresh(self):
  95. try:
  96. asyncio.get_event_loop().create_task(self._fetch_patterns())
  97. except RuntimeError:
  98. logger.debug("No running loop yet; patterns will load once started")
  99. async def _fetch_patterns(self):
  100. if not self._client.base_url:
  101. self._apply_patterns([])
  102. return
  103. try:
  104. paths = await self._client.patterns()
  105. except Exception as exc:
  106. logger.warning(f"Failed to fetch patterns: {exc}")
  107. return
  108. patterns = []
  109. for p in paths:
  110. rel = str(p).lstrip("/")
  111. # /sand_patterns may return paths with or without a /patterns prefix
  112. if rel.startswith("patterns/"):
  113. rel = rel[len("patterns/"):]
  114. patterns.append({"name": rel, "path": rel})
  115. patterns.sort(key=lambda x: x["name"].lower())
  116. self._apply_patterns(patterns)
  117. def _apply_patterns(self, patterns):
  118. self.beginResetModel()
  119. self._patterns = patterns
  120. self._filtered_patterns = self._apply_filter(patterns, self._search_text)
  121. self.endResetModel()
  122. logger.info(f"Loaded {len(self._patterns)} patterns")
  123. # ---------------------------------------------------------------- filter
  124. @staticmethod
  125. def _apply_filter(patterns, search_text):
  126. if not search_text:
  127. return list(patterns)
  128. needle = search_text.lower()
  129. return [p for p in patterns if needle in p["name"].lower()]
  130. @Slot(str)
  131. def filter(self, search_text):
  132. self._search_text = search_text or ""
  133. self.beginResetModel()
  134. self._filtered_patterns = self._apply_filter(self._patterns, self._search_text)
  135. self.endResetModel()