ExecutionPage.qml 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import QtQuick 2.15
  2. import QtQuick.Controls 2.15
  3. import QtQuick.Layouts 1.15
  4. import "../components"
  5. import "../components" as Components
  6. // Now Playing — the signature screen. Progress is not a percent bar: it is
  7. // an ember arc traced around the live pattern disc, with the ball as the
  8. // moving endpoint — the interface shows progress the way the machine makes
  9. // it. Plain Canvas drawing only (no effects layers; linuxfb-safe).
  10. Page {
  11. id: page
  12. property var backend: null
  13. property var stackView: null
  14. property string patternName: ""
  15. property string patternPreview: "" // Backend provides this via executionStarted signal
  16. readonly property bool hasPattern: (backend && backend.currentFile !== "") || patternName !== ""
  17. readonly property real progressRatio: backend ? backend.progress / 100 : 0
  18. readonly property bool inPause: backend && backend.pauseRemaining >= 0
  19. property string displayName: {
  20. var name = ""
  21. if (backend && backend.currentFile) name = backend.currentFile
  22. else if (patternName) name = patternName
  23. if (!name) return ""
  24. var parts = name.split('/')
  25. return parts[parts.length - 1].replace('.thr', '')
  26. }
  27. function formatDuration(s) {
  28. if (s < 0) return ""
  29. var h = Math.floor(s / 3600)
  30. var m = Math.floor((s % 3600) / 60)
  31. var sec = s % 60
  32. function pad(n) { return (n < 10 ? "0" : "") + n }
  33. return h > 0 ? h + ":" + pad(m) + ":" + pad(sec) : m + ":" + pad(sec)
  34. }
  35. // Direct connection to backend signals
  36. Connections {
  37. target: backend
  38. function onExecutionStarted(fileName, preview) {
  39. patternName = fileName
  40. patternPreview = preview
  41. }
  42. }
  43. Rectangle {
  44. anchors.fill: parent
  45. color: Components.ThemeManager.backgroundColor
  46. }
  47. ColumnLayout {
  48. anchors.fill: parent
  49. spacing: 0
  50. // Header
  51. Rectangle {
  52. Layout.fillWidth: true
  53. Layout.preferredHeight: Components.ThemeManager.headerHeight
  54. color: Components.ThemeManager.surfaceColor
  55. Rectangle {
  56. anchors.bottom: parent.bottom
  57. width: parent.width
  58. height: 1
  59. color: Components.ThemeManager.borderColor
  60. }
  61. RowLayout {
  62. anchors.fill: parent
  63. anchors.leftMargin: Components.ThemeManager.spaceLg
  64. anchors.rightMargin: Components.ThemeManager.spaceLg
  65. ConnectionStatus {
  66. backend: page.backend
  67. Layout.rightMargin: Components.ThemeManager.spaceSm
  68. }
  69. Label {
  70. text: "Now Playing"
  71. font.family: Components.ThemeManager.fontDisplay
  72. font.pixelSize: Components.ThemeManager.fontSizeTitle
  73. color: Components.ThemeManager.textPrimary
  74. }
  75. Item {
  76. Layout.fillWidth: true
  77. }
  78. }
  79. }
  80. // Content
  81. RowLayout {
  82. Layout.fillWidth: true
  83. Layout.fillHeight: true
  84. spacing: 0
  85. // ---- Left: the disc and its progress ring ----
  86. Item {
  87. id: stage
  88. Layout.fillHeight: true
  89. Layout.preferredWidth: page.width * 0.55
  90. Item {
  91. id: ringWrap
  92. anchors.centerIn: parent
  93. width: Math.min(stage.width, stage.height) - 2 * Components.ThemeManager.spaceXl
  94. height: width
  95. // Track + progress arc, redrawn only when progress moves
  96. Canvas {
  97. id: arcCanvas
  98. anchors.fill: parent
  99. // While weaving: fraction of the pattern drawn.
  100. // Between patterns: the countdown to the next one.
  101. property real ratio: {
  102. if (inPause)
  103. return backend && backend.pauseTotal > 0
  104. ? 1 - backend.pauseRemaining / backend.pauseTotal : 0
  105. return hasPattern ? progressRatio : 0
  106. }
  107. onRatioChanged: requestPaint()
  108. Connections {
  109. target: Components.ThemeManager
  110. function onDarkModeChanged() { arcCanvas.requestPaint() }
  111. }
  112. onPaint: {
  113. var ctx = getContext("2d")
  114. var w = width, h = height
  115. ctx.clearRect(0, 0, w, h)
  116. var cx = w / 2, cy = h / 2
  117. var lineW = 5
  118. var r = Math.min(w, h) / 2 - lineW / 2
  119. if (r <= 0)
  120. return // layout not settled yet
  121. ctx.lineWidth = lineW
  122. ctx.lineCap = "round"
  123. // Track
  124. ctx.beginPath()
  125. ctx.strokeStyle = String(Components.ThemeManager.cardColor)
  126. ctx.arc(cx, cy, r, 0, 2 * Math.PI)
  127. ctx.stroke()
  128. // Progress, from 12 o'clock
  129. if (ratio > 0) {
  130. ctx.beginPath()
  131. ctx.strokeStyle = String(Components.ThemeManager.accent)
  132. ctx.arc(cx, cy, r, -Math.PI / 2,
  133. -Math.PI / 2 + ratio * 2 * Math.PI)
  134. ctx.stroke()
  135. }
  136. }
  137. }
  138. // The ball: endpoint of the arc (two stacked dots stand in
  139. // for a glow — no shadow effects on linuxfb)
  140. Item {
  141. anchors.fill: parent
  142. rotation: arcCanvas.ratio * 360
  143. visible: (hasPattern || inPause) && arcCanvas.ratio > 0
  144. Rectangle {
  145. anchors.horizontalCenter: parent.horizontalCenter
  146. y: -7
  147. width: 19
  148. height: 19
  149. radius: 9.5
  150. color: Components.ThemeManager.accent
  151. opacity: 0.3
  152. }
  153. Rectangle {
  154. anchors.horizontalCenter: parent.horizontalCenter
  155. y: -3
  156. width: 11
  157. height: 11
  158. radius: 5.5
  159. color: Components.ThemeManager.accent
  160. }
  161. }
  162. // Live pattern disc (the preview PNG is itself a round dish)
  163. Image {
  164. anchors.fill: parent
  165. anchors.margins: 14
  166. source: patternPreview ? "file://" + patternPreview : ""
  167. fillMode: Image.PreserveAspectFit
  168. asynchronous: true
  169. visible: hasPattern && status === Image.Ready
  170. }
  171. // Resting dish when idle (or while the preview renders)
  172. Rectangle {
  173. anchors.fill: parent
  174. anchors.margins: 14
  175. radius: width / 2
  176. color: Components.ThemeManager.surfaceColor
  177. border.width: 1
  178. border.color: Components.ThemeManager.borderColor
  179. visible: !hasPattern || patternPreview === ""
  180. Column {
  181. anchors.centerIn: parent
  182. spacing: Components.ThemeManager.spaceSm
  183. Components.Icon {
  184. name: "radio_unchecked"
  185. size: 34
  186. color: Components.ThemeManager.textTertiary
  187. anchors.horizontalCenter: parent.horizontalCenter
  188. }
  189. Label {
  190. text: {
  191. if (inPause) return "Resting between patterns"
  192. return hasPattern ? "Rendering preview" : "The table is resting"
  193. }
  194. font.family: Components.ThemeManager.fontMedium
  195. font.pixelSize: Components.ThemeManager.fontSizeBody
  196. color: Components.ThemeManager.textSecondary
  197. anchors.horizontalCenter: parent.horizontalCenter
  198. }
  199. }
  200. }
  201. }
  202. }
  203. // ---- Right: name, state, transport, speed ----
  204. Rectangle {
  205. Layout.fillHeight: true
  206. Layout.fillWidth: true
  207. color: Components.ThemeManager.surfaceColor
  208. Rectangle {
  209. anchors.left: parent.left
  210. width: 1
  211. height: parent.height
  212. color: Components.ThemeManager.borderColor
  213. }
  214. ColumnLayout {
  215. anchors.fill: parent
  216. anchors.margins: Components.ThemeManager.spaceXl
  217. spacing: 0
  218. Label {
  219. text: inPause ? "Up next" : "Now weaving"
  220. font.family: Components.ThemeManager.fontDisplay
  221. font.pixelSize: 11
  222. font.letterSpacing: 1.6
  223. font.capitalization: Font.AllUppercase
  224. color: Components.ThemeManager.accent
  225. }
  226. Label {
  227. Layout.fillWidth: true
  228. Layout.topMargin: Components.ThemeManager.spaceSm
  229. text: {
  230. if (inPause && backend && backend.nextPattern)
  231. return backend.nextPattern
  232. return displayName || "Nothing playing"
  233. }
  234. font.family: Components.ThemeManager.fontDisplay
  235. font.pixelSize: Components.ThemeManager.fontSizeDisplay
  236. color: hasPattern || inPause ? Components.ThemeManager.textPrimary
  237. : Components.ThemeManager.textTertiary
  238. elide: Text.ElideRight
  239. maximumLineCount: 2
  240. wrapMode: Text.Wrap
  241. }
  242. // Playlist position
  243. Label {
  244. Layout.fillWidth: true
  245. Layout.topMargin: Components.ThemeManager.spaceXs
  246. visible: backend && backend.playlistActive && backend.playlistTotal > 0
  247. text: {
  248. if (!backend) return ""
  249. var s = (backend.playlistName ? backend.playlistName + " · " : "")
  250. + (backend.playlistIndex + 1) + " of " + backend.playlistTotal
  251. if (backend.playlistClearing) s += " · clearing"
  252. return s
  253. }
  254. font.family: Components.ThemeManager.fontBody
  255. font.pixelSize: Components.ThemeManager.fontSizeCaption
  256. color: Components.ThemeManager.textSecondary
  257. elide: Text.ElideMiddle
  258. }
  259. // Progress / pause countdown line
  260. Label {
  261. Layout.fillWidth: true
  262. Layout.topMargin: Components.ThemeManager.spaceLg
  263. visible: hasPattern || inPause
  264. textFormat: Text.StyledText
  265. text: {
  266. if (!backend) return ""
  267. if (inPause)
  268. return "<b>" + formatDuration(backend.pauseRemaining)
  269. + "</b> until the next pattern"
  270. var pct = Math.round(backend.progress)
  271. var s = "<b>" + pct + "%</b> woven"
  272. if (backend.isPaused) s += " · paused"
  273. return s
  274. }
  275. font.family: Components.ThemeManager.fontBody
  276. font.pixelSize: Components.ThemeManager.fontSizeBody
  277. color: Components.ThemeManager.textSecondary
  278. }
  279. Item { Layout.fillHeight: true }
  280. // Transport
  281. RowLayout {
  282. Layout.fillWidth: true
  283. spacing: Components.ThemeManager.spaceSm
  284. ModernControlButton {
  285. Layout.fillWidth: true
  286. Layout.preferredWidth: 3
  287. Layout.preferredHeight: Components.ThemeManager.controlHeight
  288. icon: (backend && backend.isRunning && !backend.isPaused) ? "pause" : "play_arrow"
  289. text: (backend && backend.isRunning && !backend.isPaused) ? "Pause" : "Resume"
  290. buttonColor: Components.ThemeManager.accent
  291. enabled: backend && backend.currentFile !== ""
  292. onClicked: {
  293. if (!backend) return
  294. if (backend.isPaused) backend.resumeExecution()
  295. else backend.pauseExecution()
  296. }
  297. }
  298. ModernControlButton {
  299. Layout.fillWidth: true
  300. Layout.preferredWidth: 2
  301. Layout.preferredHeight: Components.ThemeManager.controlHeight
  302. icon: "stop"
  303. text: "Stop"
  304. outlined: true
  305. buttonColor: Components.ThemeManager.danger
  306. enabled: backend !== null
  307. onClicked: if (backend) backend.stopExecution()
  308. }
  309. ModernControlButton {
  310. Layout.fillWidth: true
  311. Layout.preferredWidth: 2
  312. Layout.preferredHeight: Components.ThemeManager.controlHeight
  313. icon: "skip_next"
  314. text: "Skip"
  315. buttonColor: Components.ThemeManager.cardColor
  316. enabled: backend !== null
  317. onClicked: if (backend) backend.skipPattern()
  318. }
  319. }
  320. // Speed
  321. Label {
  322. Layout.topMargin: Components.ThemeManager.spaceLg
  323. text: "Speed · mm/s"
  324. font.family: Components.ThemeManager.fontDisplay
  325. font.pixelSize: 11
  326. font.letterSpacing: 1.4
  327. font.capitalization: Font.AllUppercase
  328. color: Components.ThemeManager.textTertiary
  329. }
  330. // Segmented control
  331. Rectangle {
  332. id: speedSeg
  333. Layout.fillWidth: true
  334. Layout.topMargin: Components.ThemeManager.spaceSm
  335. Layout.preferredHeight: 48
  336. radius: height / 2
  337. color: Components.ThemeManager.cardColor
  338. property string currentSelection: backend ? backend.getCurrentSpeedOption() : "200"
  339. Connections {
  340. target: backend
  341. function onSpeedChanged(speed) {
  342. if (backend)
  343. speedSeg.currentSelection = backend.getCurrentSpeedOption()
  344. }
  345. }
  346. Row {
  347. anchors.fill: parent
  348. anchors.margins: 4
  349. spacing: 2
  350. Repeater {
  351. model: ["50", "100", "150", "200", "300", "500"]
  352. Rectangle {
  353. property bool selected: speedSeg.currentSelection === modelData
  354. width: (parent.width - 10) / 6
  355. height: parent.height
  356. radius: height / 2
  357. color: selected ? Components.ThemeManager.backgroundColor : "transparent"
  358. border.width: selected ? 1 : 0
  359. border.color: Components.ThemeManager.borderColor
  360. Label {
  361. anchors.centerIn: parent
  362. text: modelData
  363. font.family: Components.ThemeManager.fontMedium
  364. font.pixelSize: 13
  365. color: parent.selected ? Components.ThemeManager.accent
  366. : Components.ThemeManager.textSecondary
  367. }
  368. MouseArea {
  369. anchors.fill: parent
  370. onClicked: {
  371. if (backend) {
  372. backend.setSpeedByOption(modelData)
  373. speedSeg.currentSelection = modelData
  374. }
  375. }
  376. }
  377. }
  378. }
  379. }
  380. }
  381. }
  382. }
  383. }
  384. }
  385. }