qcanvas 1.0.11__py3-none-any.whl → 2026.1.19__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 (97) hide show
  1. qcanvas/__init__.py +60 -0
  2. qcanvas/app.py +72 -0
  3. qcanvas/backend_connectors/frontend_resource_manager.py +13 -5
  4. qcanvas/backend_connectors/qcanvas_task_master.py +2 -2
  5. qcanvas/icons/__init__.py +55 -6
  6. qcanvas/icons/_icon_type.py +42 -0
  7. qcanvas/icons/icons.qrc +48 -8
  8. qcanvas/icons/rc_icons.py +2477 -566
  9. qcanvas/settings/__init__.py +6 -0
  10. qcanvas/{util/settings → settings}/_client_settings.py +15 -6
  11. qcanvas/settings/_course_settings.py +54 -0
  12. qcanvas/{util/settings → settings}/_mapped_setting.py +8 -6
  13. qcanvas/{util/settings → settings}/_ui_settings.py +5 -5
  14. qcanvas/theme.py +101 -0
  15. qcanvas/ui/course_viewer/content_tree.py +37 -19
  16. qcanvas/ui/course_viewer/course_tree/__init__.py +1 -0
  17. qcanvas/ui/course_viewer/course_tree/_course_icon_generator.py +86 -0
  18. qcanvas/ui/course_viewer/{course_tree.py → course_tree/course_tree.py} +29 -14
  19. qcanvas/ui/course_viewer/course_viewer.py +79 -46
  20. qcanvas/ui/course_viewer/tabs/assignment_tab/assignment_tab.py +107 -29
  21. qcanvas/ui/course_viewer/tabs/assignment_tab/assignment_tree.py +19 -18
  22. qcanvas/ui/course_viewer/tabs/content_tab.py +33 -39
  23. qcanvas/ui/course_viewer/tabs/file_tab/__init__.py +1 -0
  24. qcanvas/ui/course_viewer/tabs/file_tab/file_tab.py +46 -0
  25. qcanvas/ui/course_viewer/tabs/file_tab/file_tree.py +96 -0
  26. qcanvas/ui/course_viewer/tabs/file_tab/pages_file_tree.py +55 -0
  27. qcanvas/ui/course_viewer/tabs/mail_tab/mail_tab.py +50 -27
  28. qcanvas/ui/course_viewer/tabs/mail_tab/mail_tree.py +18 -19
  29. qcanvas/ui/course_viewer/tabs/page_tab/page_tab.py +3 -3
  30. qcanvas/ui/course_viewer/tabs/page_tab/page_tree.py +18 -16
  31. qcanvas/ui/course_viewer/tabs/resource_rich_browser.py +61 -74
  32. qcanvas/ui/course_viewer/tree_widget_data_item.py +22 -0
  33. qcanvas/ui/memory_tree/_tree_memory.py +45 -41
  34. qcanvas/ui/memory_tree/memory_tree_widget.py +22 -18
  35. qcanvas/ui/memory_tree/memory_tree_widget_item.py +3 -3
  36. qcanvas/ui/qcanvas_window/__init__.py +1 -0
  37. qcanvas/ui/qcanvas_window/course_viewer_container.py +95 -0
  38. qcanvas/ui/{main_ui → qcanvas_window}/options/auto_download_resources_option.py +8 -6
  39. qcanvas/ui/{main_ui → qcanvas_window}/options/quick_sync_option.py +7 -6
  40. qcanvas/ui/{main_ui → qcanvas_window}/options/sync_on_start_option.py +7 -6
  41. qcanvas/ui/{main_ui → qcanvas_window}/options/theme_selection_menu.py +12 -10
  42. qcanvas/ui/{main_ui → qcanvas_window}/qcanvas_window.py +74 -45
  43. qcanvas/ui/{main_ui → qcanvas_window}/status_bar_progress_display.py +20 -12
  44. qcanvas/ui/qml_components/__init__.py +4 -0
  45. qcanvas/ui/qml_components/attachments_pane.py +70 -0
  46. qcanvas/ui/qml_components/comments_pane.py +83 -0
  47. qcanvas/ui/qml_components/qml/AttachmentsList.ui.qml +15 -0
  48. qcanvas/ui/qml_components/qml/AttachmentsListDelegate.qml +77 -0
  49. qcanvas/ui/qml_components/qml/AttachmentsListModel.qml +19 -0
  50. qcanvas/ui/qml_components/qml/AttachmentsPane.qml +11 -0
  51. qcanvas/ui/qml_components/qml/CommentsList.ui.qml +15 -0
  52. qcanvas/ui/qml_components/qml/CommentsListDelegate.ui.qml +118 -0
  53. qcanvas/ui/qml_components/qml/CommentsListModel.qml +56 -0
  54. qcanvas/ui/qml_components/qml/CommentsPane.qml +11 -0
  55. qcanvas/ui/qml_components/qml/DecoratedText.ui.qml +44 -0
  56. qcanvas/ui/qml_components/qml/Spacer.ui.qml +7 -0
  57. qcanvas/ui/qml_components/qml/ThemedRectangle.qml +53 -0
  58. qcanvas/ui/qml_components/qml/__init__.py +3 -0
  59. qcanvas/ui/qml_components/qml/rc_qml.py +709 -0
  60. qcanvas/ui/qml_components/qml/rc_qml.qrc +16 -0
  61. qcanvas/ui/qml_components/qml_bridge_types.py +95 -0
  62. qcanvas/ui/qml_components/qml_pane.py +21 -0
  63. qcanvas/ui/setup/setup_checker.py +3 -3
  64. qcanvas/ui/setup/setup_dialog.py +173 -80
  65. qcanvas/util/__init__.py +0 -2
  66. qcanvas/util/auto_downloader.py +9 -8
  67. qcanvas/util/basic_fonts.py +2 -2
  68. qcanvas/util/context_dict.py +12 -0
  69. qcanvas/util/file_icons.py +46 -0
  70. qcanvas/util/html_cleaner.py +2 -0
  71. qcanvas/util/layouts.py +9 -8
  72. qcanvas/util/paths.py +26 -22
  73. qcanvas/util/qurl_util.py +1 -1
  74. qcanvas/util/runtime.py +20 -0
  75. qcanvas/util/ui_tools.py +121 -7
  76. qcanvas/util/url_checker.py +1 -1
  77. qcanvas-2026.1.19.dist-info/METADATA +95 -0
  78. qcanvas-2026.1.19.dist-info/RECORD +92 -0
  79. {qcanvas-1.0.11.dist-info → qcanvas-2026.1.19.dist-info}/WHEEL +1 -1
  80. qcanvas-2026.1.19.dist-info/entry_points.txt +3 -0
  81. qcanvas/app_start/__init__.py +0 -54
  82. qcanvas/icons/file-download-failed.svg +0 -6
  83. qcanvas/icons/file-downloaded.svg +0 -6
  84. qcanvas/icons/file-not-downloaded.svg +0 -6
  85. qcanvas/icons/file-unknown.svg +0 -6
  86. qcanvas/icons/main_icon.svg +0 -325
  87. qcanvas/icons/sync.svg +0 -7
  88. qcanvas/run.py +0 -30
  89. qcanvas/ui/main_ui/__init__.py +0 -0
  90. qcanvas/ui/main_ui/course_viewer_container.py +0 -52
  91. qcanvas/util/settings/__init__.py +0 -9
  92. qcanvas/util/themes.py +0 -24
  93. qcanvas-1.0.11.dist-info/METADATA +0 -61
  94. qcanvas-1.0.11.dist-info/RECORD +0 -68
  95. qcanvas-1.0.11.dist-info/entry_points.txt +0 -3
  96. /qcanvas/ui/course_viewer/tabs/{util.py → constants.py} +0 -0
  97. /qcanvas/ui/{main_ui → qcanvas_window}/options/__init__.py +0 -0
@@ -1,29 +1,39 @@
1
1
  import logging
2
2
  from threading import BoundedSemaphore
3
- from typing import *
3
+ from typing import Optional, Sequence
4
4
 
5
5
  import httpx
6
- import qcanvas_backend.database.types as db
6
+ from libqcanvas import db
7
+ from libqcanvas.database.data_monolith import DataMonolith
8
+ from libqcanvas.net.sync.sync_receipt import SyncReceipt, empty_receipt
9
+ from libqcanvas.qcanvas import QCanvas
10
+ from PySide6.QtCore import QUrl, Signal, Slot, Qt
11
+ from PySide6.QtGui import QDesktopServices, QKeySequence
12
+ from PySide6.QtWidgets import (
13
+ QErrorMessage,
14
+ QHBoxLayout,
15
+ QMainWindow,
16
+ QProgressDialog,
17
+ QPushButton,
18
+ QVBoxLayout,
19
+ QWidget,
20
+ )
7
21
  from qasync import asyncSlot
8
- from qcanvas_backend.database.data_monolith import DataMonolith
9
- from qcanvas_backend.net.sync.sync_receipt import SyncReceipt, empty_receipt
10
- from qcanvas_backend.qcanvas import QCanvas
11
- from qtpy.QtCore import QUrl, Signal, Slot
12
- from qtpy.QtGui import QDesktopServices, QIcon, QKeySequence
13
- from qtpy.QtWidgets import *
14
22
 
15
23
  from qcanvas import icons
16
24
  from qcanvas.backend_connectors import FrontendResourceManager
25
+ from qcanvas.settings import course_configs
17
26
  from qcanvas.ui.course_viewer import CourseTree
18
- from qcanvas.ui.main_ui.course_viewer_container import CourseViewerContainer
19
- from qcanvas.ui.main_ui.options.auto_download_resources_option import (
27
+ from .course_viewer_container import CourseViewerContainer
28
+ from .options.auto_download_resources_option import (
20
29
  AutoDownloadResourcesMenu,
21
30
  )
22
- from qcanvas.ui.main_ui.options.quick_sync_option import QuickSyncOption
23
- from qcanvas.ui.main_ui.options.sync_on_start_option import SyncOnStartOption
24
- from qcanvas.ui.main_ui.options.theme_selection_menu import ThemeSelectionMenu
25
- from qcanvas.ui.main_ui.status_bar_progress_display import StatusBarProgressDisplay
26
- from qcanvas.util import paths, settings, auto_downloader
31
+ from .options.quick_sync_option import QuickSyncOption
32
+ from .options.sync_on_start_option import SyncOnStartOption
33
+ from .options.theme_selection_menu import ThemeSelectionMenu
34
+ from .status_bar_progress_display import StatusBarProgressDisplay
35
+ from qcanvas.util import auto_downloader
36
+ import qcanvas.settings as settings
27
37
  from qcanvas.util.qurl_util import file_url
28
38
  from qcanvas.util.ui_tools import create_qaction
29
39
 
@@ -33,20 +43,15 @@ _logger = logging.getLogger(__name__)
33
43
  class QCanvasWindow(QMainWindow):
34
44
  _loaded = Signal()
35
45
 
36
- def __init__(self):
46
+ def __init__(self, _qcanvas: QCanvas[FrontendResourceManager]):
37
47
  super().__init__()
38
48
 
39
49
  self.setWindowTitle("QCanvas")
40
- self.setWindowIcon(QIcon(icons.main_icon))
50
+ self.setWindowIcon(icons.branding.main_icon)
41
51
 
42
52
  self._operation_semaphore = BoundedSemaphore()
43
53
  self._data: Optional[DataMonolith] = None
44
- self._qcanvas = QCanvas[FrontendResourceManager](
45
- canvas_config=settings.client.canvas_config,
46
- panopto_config=settings.client.panopto_config,
47
- storage_path=paths.data_storage(),
48
- resource_manager_class=FrontendResourceManager,
49
- )
54
+ self._qcanvas = _qcanvas
50
55
 
51
56
  self._course_tree = CourseTree()
52
57
  self._course_tree.item_selected.connect(self._on_course_selected)
@@ -62,7 +67,9 @@ class QCanvasWindow(QMainWindow):
62
67
  self._setup_menu_bar()
63
68
  self._restore_window_position()
64
69
 
65
- self._loaded.connect(self._on_app_loaded)
70
+ self._loaded.connect(
71
+ self._on_app_loaded, Qt.ConnectionType.SingleShotConnection
72
+ )
66
73
  self._loaded.emit()
67
74
 
68
75
  def _setup_menu_bar(self) -> None:
@@ -74,6 +81,7 @@ class QCanvasWindow(QMainWindow):
74
81
  shortcut=QKeySequence("Ctrl+S"),
75
82
  triggered=self._synchronise_requested,
76
83
  parent=app_menu,
84
+ icon=icons.actions.sync,
77
85
  )
78
86
 
79
87
  create_qaction(
@@ -81,17 +89,22 @@ class QCanvasWindow(QMainWindow):
81
89
  shortcut=QKeySequence("Ctrl+D"),
82
90
  triggered=self._open_downloads_folder,
83
91
  parent=app_menu,
92
+ icon=icons.actions.open_downloads,
84
93
  )
85
94
 
86
95
  create_qaction(
87
- name="Quick canvas login",
96
+ name="Open Canvas in browser",
88
97
  shortcut=QKeySequence("Ctrl+O"),
89
98
  triggered=self._open_quick_auth_in_browser,
90
99
  parent=app_menu,
100
+ icon=icons.actions.quick_login,
91
101
  )
92
102
 
93
103
  create_qaction(
94
- name="Mark all as seen", triggered=self._clear_new_items, parent=app_menu
104
+ name="Mark all as seen",
105
+ triggered=self._clear_new_items,
106
+ parent=app_menu,
107
+ icon=icons.actions.mark_all_read,
95
108
  )
96
109
 
97
110
  create_qaction(
@@ -99,10 +112,11 @@ class QCanvasWindow(QMainWindow):
99
112
  shortcut=QKeySequence("Ctrl+Q"),
100
113
  triggered=lambda: self.close(),
101
114
  parent=app_menu,
115
+ icon=icons.actions.exit,
102
116
  )
103
117
 
104
118
  options_menu = menu_bar.addMenu("Options")
105
-
119
+ options_menu.setToolTipsVisible(True)
106
120
  options_menu.addAction(QuickSyncOption(options_menu))
107
121
  options_menu.addAction(SyncOnStartOption(options_menu))
108
122
  options_menu.addMenu(AutoDownloadResourcesMenu(options_menu))
@@ -140,7 +154,6 @@ class QCanvasWindow(QMainWindow):
140
154
 
141
155
  @asyncSlot()
142
156
  async def _on_app_loaded(self) -> None:
143
- await self._qcanvas.init()
144
157
  self._course_tree.reload(await self._get_terms(), sync_receipt=empty_receipt())
145
158
 
146
159
  if settings.client.sync_on_start:
@@ -164,12 +177,18 @@ class QCanvasWindow(QMainWindow):
164
177
  except Exception as e:
165
178
  _logger.warning("Sync failed", exc_info=e)
166
179
  error = QErrorMessage(self)
167
- msg = str(e)
168
180
 
169
- if isinstance(e, httpx.ConnectError):
170
- msg = "You may not be connected to the internet\n - " + msg
181
+ if isinstance(e, ExceptionGroup):
182
+ msg = "\n".join(str(ex) for ex in e.exceptions)
183
+ elif isinstance(e, httpx.ConnectError):
184
+ msg = "You may not be connected to the Internet"
185
+ else:
186
+ msg = str(e)
171
187
 
172
- error.showMessage(msg)
188
+ error.setWindowTitle("An error has occurred in QCanvas")
189
+ error.showMessage(
190
+ msg + " - Please check the log for more details", "sync error"
191
+ )
173
192
  finally:
174
193
  self._operation_semaphore.release()
175
194
  self._sync_button.setText("Synchronise")
@@ -192,14 +211,14 @@ class QCanvasWindow(QMainWindow):
192
211
  await self._get_courses(), sync_receipt=receipt
193
212
  )
194
213
 
195
- async def _get_resources(self) -> Dict[str, db.Resource]:
196
- return (await self._qcanvas.get_data()).resources
214
+ async def _get_resources(self) -> dict[str, db.Resource]:
215
+ return (await self._qcanvas.load()).resources
197
216
 
198
217
  async def _get_terms(self) -> Sequence[db.Term]:
199
- return (await self._qcanvas.get_data()).terms
218
+ return (await self._qcanvas.load()).terms
200
219
 
201
220
  async def _get_courses(self) -> Sequence[db.Course]:
202
- return (await self._qcanvas.get_data()).courses
221
+ return (await self._qcanvas.load()).courses
203
222
 
204
223
  @Slot(db.Course)
205
224
  def _on_course_selected(self, course: Optional[db.Course]) -> None:
@@ -211,28 +230,38 @@ class QCanvasWindow(QMainWindow):
211
230
  @asyncSlot(db.Course, str)
212
231
  async def _on_course_renamed(self, course: db.Course, new_name: str) -> None:
213
232
  _logger.debug("Rename %s -> %s", course.name, new_name)
214
-
215
- async with self._qcanvas.database.session() as session:
216
- session.add(course)
217
- course.configuration.nickname = new_name
233
+ config = course_configs[course.id]
234
+ config.nickname = new_name
235
+ await config.save()
218
236
 
219
237
  @asyncSlot()
220
238
  async def _open_quick_auth_in_browser(self) -> None:
221
239
  opening_progress_dialog = QProgressDialog("Opening canvas", None, 0, 0, self)
222
240
  opening_progress_dialog.setWindowTitle("Please wait")
223
241
  opening_progress_dialog.show()
224
- QDesktopServices.openUrl(
225
- QUrl(await self._qcanvas.canvas_client.get_temporary_session_url())
226
- )
227
- opening_progress_dialog.close()
242
+
243
+ try:
244
+ open_url = QUrl(
245
+ await self._qcanvas.canvas_client.get_temporary_session_url()
246
+ )
247
+ _logger.info(f"Opening URL {open_url}")
248
+ QDesktopServices.openUrl(open_url)
249
+ finally:
250
+ opening_progress_dialog.close()
228
251
 
229
252
  @Slot()
230
253
  def _open_downloads_folder(self) -> None:
231
254
  directory = self._qcanvas.resource_manager.downloads_folder
255
+
256
+ if self._course_viewer_container.selected_course is not None:
257
+ directory /= self._qcanvas.resource_manager.course_folder_name(
258
+ self._course_viewer_container.selected_course
259
+ )
260
+
232
261
  directory.mkdir(parents=True, exist_ok=True)
233
262
 
234
263
  QDesktopServices.openUrl(file_url(directory))
235
264
 
236
265
  @asyncSlot()
237
266
  async def _clear_new_items(self) -> None:
238
- await self._reload(None)
267
+ await self._reload(empty_receipt())
@@ -1,11 +1,10 @@
1
1
  import logging
2
2
  from asyncio import Lock
3
3
  from dataclasses import dataclass
4
- from typing import *
5
4
 
5
+ from libqcanvas.task_master import TaskID
6
+ from PySide6.QtWidgets import QProgressBar, QStatusBar
6
7
  from qasync import asyncSlot
7
- from qcanvas_backend.task_master import TaskID
8
- from qtpy.QtWidgets import *
9
8
 
10
9
  from qcanvas.backend_connectors import task_master
11
10
 
@@ -77,35 +76,44 @@ class StatusBarProgressDisplay(QStatusBar):
77
76
  async with self._lock:
78
77
  if self._has_no_tasks:
79
78
  self._show_done()
80
- elif self._has_single_task:
81
- self._show_single_task_progress(list(self._tasks.items())[0])
82
79
  else:
83
- self._show_multiple_tasks_progress(list(self._tasks.values()))
80
+ tasks = list(self._tasks.items())
81
+
82
+ if self._has_single_task:
83
+ self._show_single_task_progress(tasks[0])
84
+ else:
85
+ self._show_multiple_tasks_progress(tasks)
84
86
 
85
87
  def _show_done(self) -> None:
86
88
  _logger.info("Finished tasks. Tasks: %s", self._tasks)
87
89
  self.showMessage("Done", 5000)
88
90
  self._progress_bar.hide()
89
91
 
90
- def _show_single_task_progress(self, task: Tuple[TaskID, _TaskProgress]) -> None:
92
+ def _show_single_task_progress(self, task: tuple[TaskID, _TaskProgress]) -> None:
91
93
  _logger.debug("Single task %s", task)
92
94
  id, progress = task
93
95
 
94
96
  self._show_progress(progress)
95
97
  self.showMessage(id.step_name)
96
98
 
97
- def _show_multiple_tasks_progress(self, tasks: list[_TaskProgress]) -> None:
99
+ def _show_multiple_tasks_progress(
100
+ self, tasks: list[tuple[TaskID, _TaskProgress]]
101
+ ) -> None:
98
102
  _logger.debug("Multiple tasks %s", tasks)
99
- self.showMessage(f"{len(tasks)} tasks in progress")
103
+ self.showMessage(
104
+ f"{len(tasks)} tasks in progress - {', '.join([task[0].step_name for task in tasks])}"
105
+ )
100
106
  self._show_progress(self._calculate_progress(tasks))
101
107
 
102
- def _calculate_progress(self, tasks: list[_TaskProgress]) -> _TaskProgress:
103
- # Used to represent 0..1 progress as 0..multiplier
108
+ def _calculate_progress(
109
+ self, tasks: list[tuple[TaskID, _TaskProgress]]
110
+ ) -> _TaskProgress:
111
+ # Task progresses are floats from 0 to 1, multiplier is used to turn them into ints
104
112
  multiplier = 1000
105
113
  current_sum = 0
106
114
  total_sum = 0
107
115
 
108
- for task in tasks:
116
+ for _, task in tasks:
109
117
  if task.total != 0:
110
118
  current_sum += (task.current / task.total) * multiplier
111
119
 
@@ -0,0 +1,4 @@
1
+ from .qml import rc_qml # Do not remove!! Loads the qml resources
2
+ from .comments_pane import CommentsPane
3
+ from .attachments_pane import AttachmentsPane
4
+ from .qml_bridge_types import Attachment, Comment
@@ -0,0 +1,70 @@
1
+ from PySide6.QtCore import Slot, QUrl
2
+ from PySide6.QtWidgets import QWidget
3
+ from qasync import asyncSlot
4
+
5
+ from qcanvas.backend_connectors import FrontendResourceManager
6
+ from .qml_bridge_types import Attachment
7
+ from .qml_pane import QmlPane
8
+ from libqcanvas import db
9
+ import logging
10
+
11
+ _logger = logging.getLogger(__name__)
12
+
13
+
14
+ class AttachmentsPane(QmlPane):
15
+ def __init__(
16
+ self, downloader: FrontendResourceManager, parent: QWidget | None = None
17
+ ):
18
+ super().__init__(qml_path=QUrl("qrc:/qml/AttachmentsPane.qml"), parent=parent)
19
+
20
+ self._original_dock_name = None
21
+ self._downloader = downloader
22
+ self._files: dict[str, db.Resource] = {}
23
+ self._qfiles: dict[str, Attachment] = {}
24
+ self.ctx["submission_files"] = []
25
+ self.load_view()
26
+
27
+ self._downloader.download_finished.connect(self._download_updated)
28
+ self._downloader.download_failed.connect(self._download_updated)
29
+
30
+ def clear_files(self):
31
+ self.ctx["submission_files"] = []
32
+ self._files.clear()
33
+ self._qfiles.clear()
34
+
35
+ def load_files(self, files: list[db.Resource]):
36
+ qfiles = []
37
+
38
+ if self._original_dock_name is None:
39
+ self._original_dock_name = self.parent().windowTitle()
40
+
41
+ self.parent().setWindowTitle(f"{self._original_dock_name} ({len(files)})")
42
+
43
+ for file in files:
44
+ qfile = Attachment(
45
+ file_name=file.file_name,
46
+ resource_id=file.id,
47
+ download_state=file.download_state,
48
+ )
49
+ qfile.opened.connect(self._on_attachment_opened)
50
+ qfiles.append(qfile)
51
+
52
+ self._qfiles[file.id] = qfile
53
+ self._files[file.id] = file
54
+
55
+ self.ctx["submission_files"] = qfiles
56
+
57
+ @asyncSlot(str)
58
+ async def _on_attachment_opened(self, resource_id: str) -> None:
59
+ if resource_id in self._files:
60
+ await self._downloader.download_and_open(self._files[resource_id])
61
+ else:
62
+ _logger.warning(
63
+ "User opened an attachment that doesn't belong to any comment! id=%s",
64
+ resource_id,
65
+ )
66
+
67
+ @Slot(db.Resource)
68
+ def _download_updated(self, resource: db.Resource) -> None:
69
+ if resource.id in self._files:
70
+ self._qfiles[resource.id].download_state = resource.download_state
@@ -0,0 +1,83 @@
1
+ from PySide6.QtCore import Signal, Slot, QUrl
2
+ from PySide6.QtWidgets import QWidget
3
+ from libqcanvas.util import remove_unwanted_whitespaces, as_local
4
+ from qasync import asyncSlot
5
+
6
+ from qcanvas.backend_connectors import FrontendResourceManager
7
+ from .qml_bridge_types import Attachment, Comment
8
+ from libqcanvas import db
9
+ import logging
10
+ from .qml_pane import QmlPane
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+
15
+ class CommentsPane(QmlPane):
16
+ attachment_opened = Signal(str)
17
+
18
+ def __init__(
19
+ self, downloader: FrontendResourceManager, parent: QWidget | None = None
20
+ ):
21
+ super().__init__(qml_path=QUrl("qrc:/qml/CommentsPane.qml"), parent=parent)
22
+ self._downloader = downloader
23
+ self._attachments: dict[str, db.Resource] = {}
24
+ self._qattachments: dict[str, Attachment] = {}
25
+
26
+ # Add context objects before we load the view
27
+ self.ctx["comments"] = []
28
+ self.load_view()
29
+
30
+ self._downloader.download_finished.connect(self._download_updated)
31
+ self._downloader.download_failed.connect(self._download_updated)
32
+
33
+ def clear_comments(self) -> None:
34
+ self.ctx["comments"] = []
35
+ self._attachments.clear()
36
+ self._qattachments.clear()
37
+
38
+ def load_comments(self, comments: list[db.SubmissionComment]) -> None:
39
+ qcomments = []
40
+
41
+ self.parent().setWindowTitle(f"Comments ({len(comments)})")
42
+
43
+ for comment in comments:
44
+ attachments = []
45
+
46
+ for attachment in comment.attachments:
47
+ qattachment = Attachment(
48
+ file_name=attachment.file_name,
49
+ resource_id=attachment.id,
50
+ download_state=attachment.download_state,
51
+ )
52
+ qattachment.opened.connect(self._on_attachment_opened)
53
+ attachments.append(qattachment)
54
+
55
+ self._attachments[attachment.id] = attachment
56
+ self._qattachments[attachment.id] = qattachment
57
+
58
+ qcomments.append(
59
+ Comment(
60
+ body=remove_unwanted_whitespaces(comment.body),
61
+ author=comment.author,
62
+ date=as_local(comment.creation_date).strftime("%Y-%m-%d %H:%M"),
63
+ attachments=attachments,
64
+ parent=self,
65
+ )
66
+ )
67
+
68
+ self.ctx["comments"] = qcomments
69
+
70
+ @asyncSlot(str)
71
+ async def _on_attachment_opened(self, resource_id: str) -> None:
72
+ if resource_id in self._attachments:
73
+ await self._downloader.download_and_open(self._attachments[resource_id])
74
+ else:
75
+ _logger.warning(
76
+ "User opened an attachment that doesn't belong to any comment! id=%s",
77
+ resource_id,
78
+ )
79
+
80
+ @Slot(db.Resource)
81
+ def _download_updated(self, resource: db.Resource) -> None:
82
+ if resource.id in self._qattachments:
83
+ self._qattachments[resource.id].download_state = resource.download_state
@@ -0,0 +1,15 @@
1
+
2
+
3
+ /*
4
+ This is a UI file (.ui.qml) that is intended to be edited in Qt Design Studio only.
5
+ It is supposed to be strictly declarative and only uses a subset of QML. If you edit
6
+ this file manually, you might introduce QML code that is not supported by Qt Design Studio.
7
+ Check out https://doc.qt.io/qtcreator/creator-quick-ui-forms.html for details on .ui.qml files.
8
+ */
9
+ import QtQuick
10
+
11
+ ListView {
12
+ id: view
13
+ model: AttachmentsListModel {}
14
+ delegate: AttachmentsListDelegate {}
15
+ }
@@ -0,0 +1,77 @@
1
+ import QtQuick
2
+ import QtQuick.Layouts
3
+
4
+ Item {
5
+ id: delegate
6
+ height: childrenRect.height
7
+ anchors {
8
+ left: parent.left
9
+ right: parent.right
10
+ }
11
+
12
+ ColumnLayout {
13
+ spacing: 0
14
+
15
+ anchors {
16
+ left: parent.left
17
+ right: parent.right
18
+ }
19
+
20
+ RowLayout {
21
+ height: childrenRect.implicitHeight
22
+ Layout.fillWidth: true
23
+ spacing: 10
24
+
25
+ Image {
26
+ function attachmentIcon(downloadStatus) {
27
+ switch(downloadStatus)
28
+ {
29
+ case "DOWNLOADED":
30
+ return "qrc:///icons/universal/downloads/downloaded.svg";
31
+ case "FAILED":
32
+ return "qrc:///icons/universal/downloads/download_failed.svg";
33
+ case "NOT_DOWNLOADED":
34
+ return "qrc:///icons/universal/downloads/not_downloaded.svg";
35
+ default:
36
+ return "qrc:///icons/universal/downloads/unknown.svg"
37
+ }
38
+ }
39
+
40
+ id: fileIcon
41
+ source: attachmentIcon(modelData.download_state)
42
+ fillMode: Image.PreserveAspectFit
43
+ sourceSize.height: 17
44
+ sourceSize.width: 17
45
+ }
46
+
47
+ Text {
48
+ id: file_name_text
49
+ text: modelData.file_name
50
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
51
+ Layout.fillWidth: true
52
+ font.underline: true
53
+ color: palette.link
54
+
55
+ MouseArea {
56
+ id: textClickArea
57
+ anchors.fill: parent
58
+ cursorShape: Qt.PointingHandCursor
59
+ hoverEnabled: true
60
+ }
61
+ }
62
+ }
63
+
64
+ Spacer {
65
+ size: 5
66
+ visible: index !== count - 1
67
+ }
68
+ }
69
+
70
+ Connections {
71
+ target: textClickArea
72
+
73
+ function onClicked() {
74
+ modelData.opened(modelData.resource_id)
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,19 @@
1
+ import QtQuick
2
+
3
+ ListModel {
4
+ ListElement {
5
+ file_name: "texas.pdf"
6
+ resource_id: "1"
7
+ download_state: "NOT_DOWNLOADED"
8
+ }
9
+ ListElement {
10
+ file_name: "oh_no_what_a_terribly_long_file_name_its_not_like_someone_would_actually_do_this.pdf"
11
+ resource_id: "2"
12
+ download_state: "FAILED"
13
+ }
14
+ ListElement {
15
+ file_name: "i was transported to another world where javascript doesn't exist.cbz"
16
+ resource_id: "3"
17
+ download_state: "DOWNLOADED"
18
+ }
19
+ }
@@ -0,0 +1,11 @@
1
+ import QtQuick
2
+
3
+ ThemedRectangle {
4
+ anchors.fill: parent
5
+
6
+ AttachmentsList {
7
+ anchors.fill: parent
8
+ model: submission_files
9
+ palette: theme
10
+ }
11
+ }
@@ -0,0 +1,15 @@
1
+
2
+
3
+ /*
4
+ This is a UI file (.ui.qml) that is intended to be edited in Qt Design Studio only.
5
+ It is supposed to be strictly declarative and only uses a subset of QML. If you edit
6
+ this file manually, you might introduce QML code that is not supported by Qt Design Studio.
7
+ Check out https://doc.qt.io/qtcreator/creator-quick-ui-forms.html for details on .ui.qml files.
8
+ */
9
+ import QtQuick
10
+
11
+ ListView {
12
+ id: view
13
+ model: CommentsListModel {}
14
+ delegate: CommentsListDelegate {}
15
+ }