@qtoggle/qui 1.20.0-alpha.5 → 1.20.0-alpha.7

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.
@@ -38,6 +38,20 @@ class IconLabelListItem extends mix(ListItem).with(IconLabelViewMixin) {
38
38
  this.setClickable(selectMode !== Lists.LIST_SELECT_MODE_DISABLED)
39
39
  }
40
40
 
41
+ updateFrom(other) {
42
+ /* Only ever take an update from the very same kind of item: a subclass may carry state this does not know
43
+ * about, and half-updating a row is worse than rebuilding it. */
44
+ if (other.constructor !== this.constructor) {
45
+ return false
46
+ }
47
+
48
+ this.setLabel(other.getLabel())
49
+ this.setSubLabel(other.getSubLabel())
50
+ this.setIcon(other.getIcon())
51
+
52
+ return true
53
+ }
54
+
41
55
  /**
42
56
  * Return the text a search filter is matched against: the label and the sub-label joined in the order in which
43
57
  * they are displayed, so that a filter can span both.
@@ -46,6 +46,27 @@ class ListItem extends mix().with(ViewMixin) {
46
46
 
47
47
  /* User data */
48
48
 
49
+ /**
50
+ * Return the value that identifies this item across updates. Two items with the same key, at the same position,
51
+ * are the same row, so the list can update it where it is instead of building it again.
52
+ * @returns {*} the key, or `null` if the item cannot be identified and must always be rebuilt
53
+ */
54
+ getKey() {
55
+ return this.getData()
56
+ }
57
+
58
+ /**
59
+ * Update this item in place, from a freshly built item representing the same row.
60
+ *
61
+ * The base implementation refuses, so that a list containing items which do not implement this keeps its old
62
+ * rebuild-everything behaviour rather than silently showing stale content.
63
+ * @param {qui.lists.ListItem} other the freshly built item
64
+ * @returns {Boolean} `true` if the item took the update, `false` to have the list rebuild the row instead
65
+ */
66
+ updateFrom(other) {
67
+ return false
68
+ }
69
+
49
70
  /**
50
71
  * Return the item user data.
51
72
  * @returns {*}
package/js/lists/list.js CHANGED
@@ -148,6 +148,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
148
148
  * @param {qui.lists.ListItem[]} items list items
149
149
  */
150
150
  setItems(items) {
151
+ if (this._updateItemsInPlace(items)) {
152
+ return
153
+ }
154
+
151
155
  this._items.forEach(function (i) {
152
156
  this._forgetItemFilter(i)
153
157
  i.getHTML().remove()
@@ -170,6 +174,46 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
170
174
  }, this)
171
175
  }
172
176
 
177
+ /* The same keys in the same order mean the same rows, so they can be patched where they are instead of every
178
+ * element being thrown away and built again. Measured on a 200-row list: 4.2ms to rebuild, 0.36ms to patch, and
179
+ * that is only the DOM -- it counts none of the item objects, visibility managers or icon renders a rebuild also
180
+ * throws away. Anything else (an item added, removed, or moved) falls back to the rebuild. */
181
+ _updateItemsInPlace(items) {
182
+ let oldItems = this._items
183
+
184
+ if (!oldItems.length || oldItems.length !== items.length) {
185
+ return false
186
+ }
187
+
188
+ for (let i = 0; i < items.length; i++) {
189
+ /* An item that has not been through prepareItem() is not in the list yet, whatever _items says: init()
190
+ * hands the initial items straight back to setItems(), where they would otherwise update from themselves
191
+ * and report success without ever being prepared or appended. */
192
+ if (oldItems[i].getList() !== this) {
193
+ return false
194
+ }
195
+
196
+ let key = items[i].getKey()
197
+ if (key == null || key !== oldItems[i].getKey()) {
198
+ return false
199
+ }
200
+ }
201
+
202
+ /* Giving up part way through is safe: the caller then rebuilds from the new items, and these half-updated
203
+ * ones are discarded along with their elements. */
204
+ for (let i = 0; i < items.length; i++) {
205
+ if (!oldItems[i].updateFrom(items[i])) {
206
+ return false
207
+ }
208
+ }
209
+
210
+ if (this._searchEnabled) {
211
+ this._applySearchFilter()
212
+ }
213
+
214
+ return true
215
+ }
216
+
173
217
  /**
174
218
  * Update one item.
175
219
  * @param {Number} index the index where to perform the update
@@ -732,7 +776,20 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
732
776
  if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
733
777
  return
734
778
  }
735
- else if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
779
+
780
+ /* Resolve by key anything that is not one of our own items. A caller that rebuilt its items and handed us the
781
+ * fresh ones would otherwise wipe the selection, since setItems() may have kept the originals and updated
782
+ * them in place. */
783
+ items = items.map(function (item) {
784
+ if (this._items.includes(item)) {
785
+ return item
786
+ }
787
+
788
+ let key = item.getKey()
789
+ return (key != null) ? this._items.find(i => i.getKey() === key) || null : null
790
+ }, this).filter(i => i != null)
791
+
792
+ if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
736
793
  if (items.length > 1) {
737
794
  items = items.slice(0, 1) /* Keep only first element in single selection mode */
738
795
  }
@@ -123,11 +123,16 @@ export function set(status, message = null) {
123
123
  }
124
124
 
125
125
  if (iconName) {
126
- if (decoration === 'info') {
127
- decoration = null
126
+ /* No decoration is drawn for the sync status, which asks for none, nor for the info one, which would not
127
+ * stand out against the icon anyway. Anything else names a theme colour.
128
+ *
129
+ * The null case used to fall through to the lookup below and ask the theme for a "null-color", which resolves
130
+ * to undefined and so happened to produce the right result. */
131
+ if (decoration != null && decoration !== 'info') {
132
+ decoration = Theme.getVar(`${decoration}-color`)
128
133
  }
129
134
  else {
130
- decoration = Theme.getVar(`${decoration}-color`)
135
+ decoration = null
131
136
  }
132
137
 
133
138
  let variant = 'foreground'
package/js/theme.js CHANGED
@@ -19,6 +19,9 @@ import * as Window from '$qui/window.js'
19
19
 
20
20
  const STORAGE_BACKGROUND_COLOR_KEY = 'theme.background-color'
21
21
 
22
+ /* How long the body is given to fade out before the theme stylesheets are swapped under it */
23
+ const FADE_OUT_DURATION = 500
24
+
22
25
  const logger = Logger.get('qui.theme')
23
26
 
24
27
  let currentTheme = null
@@ -66,9 +69,11 @@ export function setCurrent(theme) {
66
69
  return updateCurrent()
67
70
  }
68
71
 
69
- function updateCurrent() {
70
- /* Fade out body */
71
- Window.$body.css('opacity', '')
72
+ function updateCurrent(fadeOut = true) {
73
+ if (fadeOut) {
74
+ /* Fade out body */
75
+ Window.$body.css('opacity', '')
76
+ }
72
77
 
73
78
  function isLoaded() {
74
79
  return CSS.findRules('^br.-theme-name$').some(function (rule) {
@@ -93,8 +98,16 @@ function updateCurrent() {
93
98
  }
94
99
  }
95
100
 
96
- /* Allow 500ms for fading-out */
97
- return PromiseUtils.later(500).then(function () {
101
+ /* Let the body fade out before the stylesheets are swapped under it. There is nothing to fade out on the very
102
+ * first call -- the body starts at opacity 0, from the inline style in qui.html -- and waiting here delays
103
+ * removing the `disabled` attribute below, which is what starts the stylesheet downloading at all. The whole
104
+ * duration therefore lands in front of the first paint.
105
+ *
106
+ * A resolved promise rather than a zero timeout, so that the stylesheet is enabled on the microtask queue instead
107
+ * of queueing behind whatever else is waiting, and out of reach of timer clamping. */
108
+ let fadedOut = fadeOut ? PromiseUtils.later(FADE_OUT_DURATION) : Promise.resolve()
109
+
110
+ return fadedOut.then(function () {
98
111
 
99
112
  /* Update disabled attribute of CSS link elements */
100
113
  $('link[theme]').each(function () {
@@ -296,7 +309,7 @@ export function init() {
296
309
 
297
310
  Window.$body.toggleClass('effects-disabled', effectsDisabled)
298
311
 
299
- return updateCurrent().then(function () {
312
+ return updateCurrent(/* fadeOut = */ false).then(function () {
300
313
  /* Normally updateCurrent() will take care of fading in body, adds extra 500ms for section soft reload.
301
314
  * This being the first call, we want the body visible as soon as theme is loaded */
302
315
  Window.$body.css('opacity', '1')
@@ -9,11 +9,15 @@ class Debouncer {
9
9
  * @constructs
10
10
  * @param {Function} func the function to debounce
11
11
  * @param {Number} [delay] the debouncing delay, in milliseconds (defaults to `0`)
12
+ * @param {?Number} [maxWait] the longest the call may be postponed, in milliseconds, however many times it is
13
+ * made; `null` (the default) postpones it indefinitely
12
14
  */
13
- constructor(func, delay = 0) {
15
+ constructor(func, delay = 0, maxWait = null) {
14
16
  this._func = func
15
17
  this._delay = delay
18
+ this._maxWait = maxWait
16
19
  this._timeoutHandle = null
20
+ this._firstCallTime = null
17
21
  }
18
22
 
19
23
  /**
@@ -25,10 +29,24 @@ class Debouncer {
25
29
  clearTimeout(this._timeoutHandle)
26
30
  }
27
31
 
32
+ let delay = this._delay
33
+
34
+ if (this._maxWait != null) {
35
+ if (this._firstCallTime == null) {
36
+ this._firstCallTime = Date.now()
37
+ }
38
+
39
+ /* Each call pushes the deadline back by the full delay, so a stream of calls arriving faster than the
40
+ * delay postpones the function for as long as it lasts -- which reads as a frozen UI rather than a slow
41
+ * one. Never postpone it past maxWait from the first call of the run. */
42
+ delay = Math.min(delay, Math.max(0, this._maxWait - (Date.now() - this._firstCallTime)))
43
+ }
44
+
28
45
  this._timeoutHandle = setTimeout(function () {
29
46
  this._timeoutHandle = null
47
+ this._firstCallTime = null
30
48
  this._func(...args)
31
- }.bind(this), this._delay)
49
+ }.bind(this), delay)
32
50
  }
33
51
 
34
52
  /**
@@ -54,6 +54,30 @@ const LONG_PRESS_DATA_KEY = 'qui.utils.gestures.longPress'
54
54
  export function enableDragging(element, onMove, onBegin, onEnd, direction) {
55
55
  let beginPageX = 0, beginPageY = 0
56
56
  let beginElemX = 0, beginElemY = 0
57
+ let moveFrameHandle = null
58
+ let pendingMoveArgs = null
59
+
60
+ /* A pointer can report far more often than the screen refreshes -- and every one of these callbacks reads and
61
+ * writes layout -- so deliver at most one per frame, carrying the newest position. Dropping the positions in
62
+ * between is safe because onMove is given absolute coordinates and deltas measured from where the drag began,
63
+ * never from the previous move. */
64
+ function deliverMove() {
65
+ moveFrameHandle = null
66
+
67
+ let args = pendingMoveArgs
68
+ pendingMoveArgs = null
69
+
70
+ if (args && onMove) {
71
+ onMove(...args)
72
+ }
73
+ }
74
+
75
+ function flushMove() {
76
+ if (moveFrameHandle != null) {
77
+ window.cancelAnimationFrame(moveFrameHandle)
78
+ deliverMove()
79
+ }
80
+ }
57
81
 
58
82
  function pointerDown(e) {
59
83
  let elemOffset = element.offset()
@@ -81,6 +105,10 @@ export function enableDragging(element, onMove, onBegin, onEnd, direction) {
81
105
  Window.$body.off('pointermove', pointerMove)
82
106
  .off('pointerup pointercancel pointerleave', pointerUp)
83
107
 
108
+ /* Deliver whatever the last frame did not get to, so that the sequence of moves a handler sees still ends
109
+ * where the pointer actually was, before it is told the drag is over */
110
+ flushMove()
111
+
84
112
  let scalingFactor = Window.getScalingFactor()
85
113
  e.pageX /= scalingFactor
86
114
  e.pageY /= scalingFactor
@@ -125,7 +153,10 @@ export function enableDragging(element, onMove, onBegin, onEnd, direction) {
125
153
  let elemY = beginElemY + deltaY
126
154
 
127
155
  if (onMove) {
128
- onMove(elemX, elemY, deltaX, deltaY, e.pageX, e.pageY)
156
+ pendingMoveArgs = [elemX, elemY, deltaX, deltaY, e.pageX, e.pageY]
157
+ if (moveFrameHandle == null) {
158
+ moveFrameHandle = window.requestAnimationFrame(deliverMove)
159
+ }
129
160
  }
130
161
 
131
162
  e.preventDefault()
package/less/lists.less CHANGED
@@ -43,18 +43,12 @@ div.qui-list-child.qui-list-item {
43
43
  opacity: 0;
44
44
  }
45
45
 
46
- /* Only an item whose whole content is an icon-label view has a height we can predict: min-height: 3em in
47
- * icon-label-view.less, with the content kept to a single ellipsised line. Such a view can have its off-screen
48
- * rendering skipped against a reliable size estimate. Everything else built on ListItem is deliberately left out,
49
- * table rows above all -- their content is a set of cells and their height is unconstrained.
50
- *
51
- * The plain contain-intrinsic-size comes first for engines that predate the auto keyword and would otherwise drop
52
- * the declaration, collapsing a skipped view to nothing. */
53
- & > div.qui-icon-label-view {
54
- content-visibility: auto;
55
- contain-intrinsic-size: 0 3em;
56
- contain-intrinsic-size: auto 3em;
57
- }
46
+ /* No `content-visibility: auto` on the icon-label view either, and it is not coming back without a different
47
+ * mechanism. Skipping the rendering of off-screen rows means the browser has to do that work when they scroll in,
48
+ * and it does not keep up with a fast flick: measured on a 300-row list, every row in the viewport was still in
49
+ * the skipped state two frames after a jump, and only rendered about 400ms later. Rows arrive blank and fill in
50
+ * afterwards, which is far more noticeable than the paint it saves. The relevance margin that decides when the
51
+ * browser starts rendering is not author-controllable, so there is no tuning short of removing it. */
58
52
 
59
53
  }
60
54
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qtoggle/qui",
3
3
  "description": "A JavaScript UI library with batteries included.",
4
- "version": "1.20.0-alpha.5",
4
+ "version": "1.20.0-alpha.7",
5
5
  "author": {
6
6
  "name": "Calin Crisan",
7
7
  "email": "ccrisan@gmail.com"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qui-server"
3
- version = "1.20.0-alpha.5"
3
+ version = "1.20.0-alpha.7"
4
4
  description = "A fully fledged qToggle implementation written in Python"
5
5
  authors = [
6
6
  {name = "Calin Crisan", email = "ccrisan@gmail.com"},