@qtoggle/qui 1.20.0-alpha.4 → 1.20.0-alpha.6

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()
@@ -9,8 +9,8 @@ div.qui-global-glass {
9
9
  left: 0;
10
10
  right: 0;
11
11
  bottom: 0;
12
+ /* backdrop-filter is set once below and never changes, so listing it here only ever cost a property to watch */
12
13
  transition: opacity @transition-duration ease,
13
- backdrop-filter @transition-duration linear,
14
14
  margin-top @transition-duration linear;
15
15
  opacity: 0;
16
16
  display: flex;
package/less/lists.less CHANGED
@@ -29,10 +29,15 @@ div.qui-list-child {
29
29
 
30
30
  div.qui-list-child.qui-list-item {
31
31
 
32
- /* Nothing inside an item is ever painted outside of it, since div.qui-list-child above already clips with
33
- * overflow: hidden. Containment is therefore visually inert here, but it lets the engine scope layout and style
34
- * invalidation to the single row that changed, instead of the whole list, and skip painting off-screen rows. */
35
- contain: layout style paint;
32
+ /* No `contain` here, deliberately. Layout or paint containment makes each row an independent unit for pixel
33
+ * snapping, and the separator below is a 0.0625em border -- one device pixel at the default font size, but a
34
+ * fractional one as soon as the page is zoomed or the root font size is not 16px. Snapped per row rather than
35
+ * across the list, some of those borders round away to nothing and the separator simply vanishes for a few rows.
36
+ * Reproduced at a 53.6px row pitch: 7 of 10 separators drawn with containment, 10 of 10 without. `contain: paint`
37
+ * alone and `contain: layout style` alone each do it; only `contain: style` is safe, and that buys nothing.
38
+ *
39
+ * The off-screen saving is not lost: content-visibility on the icon-label view below implies containment on that
40
+ * element, which carries no border of its own. */
36
41
 
37
42
  &.hidden {
38
43
  opacity: 0;
package/less/main-ui.less CHANGED
@@ -24,11 +24,13 @@ div.qui-main-container-glass {
24
24
  right: 0;
25
25
  bottom: 0;
26
26
  left: 0;
27
- transition: backdrop-filter @transition-duration linear;
28
- }
27
+ opacity: 0;
29
28
 
30
- div.qui-main-container-glass.visible {
29
+ /* The filter is constant and only the opacity of the filtered layer is animated. Transitioning backdrop-filter
30
+ * itself gives the compositor a different filter to apply on every frame, across the whole viewport, and nothing
31
+ * it can cache. There is no cost while hidden: VisibilityManager sets display: none once the fade has finished. */
31
32
  backdrop-filter: @modal-background-filter;
33
+ transition: opacity @transition-duration linear;
32
34
 
33
35
  /* Workaround for Firefox that doesn't support backdrop-filter property */
34
36
  @supports (-moz-appearance:none) {
@@ -36,6 +38,10 @@ div.qui-main-container-glass.visible {
36
38
  }
37
39
  }
38
40
 
41
+ div.qui-main-container-glass.visible {
42
+ opacity: 1;
43
+ }
44
+
39
45
  div.qui-pages-container {
40
46
  height: 100%;
41
47
  }
@@ -27,10 +27,36 @@ div.qui-page.visible {
27
27
  z-index: 1;
28
28
  }
29
29
 
30
- body.small-screen > div.qui-main-container > div.qui-pages-container > div.qui-page.visible,
31
- body.small-screen > div.qui-global-glass > div.qui-global-glass-container > div.qui-page.visible {
30
+ /* On a small screen a page is always exactly as wide as its container, so translateX(100%) lands in the very same
31
+ * place as left: 100% -- but on the compositor, instead of relayouting and repainting both page subtrees on every
32
+ * frame of the slide.
33
+ *
34
+ * The resting state is deliberately `transform: none` and not `translateX(0)`: any transform other than `none` makes
35
+ * the page a containing block for its `position: fixed` descendants, which would unpin the form button bar (see
36
+ * forms/form.less). Interpolating out of `none` animates perfectly well, and while a page is actually sliding, its
37
+ * button bar travelling with it is what one wants anyway.
38
+ *
39
+ * The desktop column layout deliberately stays on `left`/`width`: there a page is narrower than its container, so
40
+ * translateX(100%) would not carry it clear of the viewport, and the slide distance would have to be computed per
41
+ * page against its own width. */
42
+ body.small-screen > div.qui-main-container > div.qui-pages-container > div.qui-page,
43
+ body.small-screen > div.qui-global-glass > div.qui-global-glass-container > div.qui-page {
32
44
  width: 100% !important;
33
45
  left: 0 !important;
46
+ transform: translateX(100%);
47
+ transition: opacity @transition-duration ease-out,
48
+ transform @transition-duration ease;
49
+
50
+ &.attached {
51
+ transform: translateX(-100%);
52
+ }
53
+
54
+ /* Popups fade in place; they never slide */
55
+ &.popup,
56
+ &.visible {
57
+ transform: none;
58
+ }
59
+
34
60
  }
35
61
 
36
62
  div.qui-page.current {
@@ -20,7 +20,7 @@ div.qui-check-button {
20
20
  transition+: background @transition-duration ease,
21
21
  color @transition-duration ease,
22
22
  opacity @transition-duration ease,
23
- border @transition-duration ease;
23
+ border-color @transition-duration ease;
24
24
  .qui-focusable-widget();
25
25
 
26
26
  &:FOCUS {
@@ -4,11 +4,14 @@
4
4
  @import (reference) "common";
5
5
 
6
6
 
7
+ /* border-color, not the border shorthand: the width and style of these borders are set once and never animate, so
8
+ * naming the colour keeps the fade while leaving border-width and border-style -- the two that would cost layout --
9
+ * out of the transition entirely. */
7
10
  .qui-base-button {
8
11
  transition: background @transition-duration ease,
9
12
  color @transition-duration ease,
10
13
  opacity @transition-duration ease,
11
- border @transition-duration ease;
14
+ border-color @transition-duration ease;
12
15
  box-sizing: border-box;
13
16
  cursor: pointer;
14
17
  .noselect;
@@ -73,7 +76,7 @@
73
76
  transition+: background @transition-duration ease,
74
77
  color @transition-duration ease,
75
78
  opacity @transition-duration ease,
76
- border @transition-duration ease;
79
+ border-color @transition-duration ease;
77
80
  .qui-focusable-widget();
78
81
 
79
82
  &:FOCUS {
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.4",
4
+ "version": "1.20.0-alpha.6",
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.4"
3
+ version = "1.20.0-alpha.6"
4
4
  description = "A fully fledged qToggle implementation written in Python"
5
5
  authors = [
6
6
  {name = "Calin Crisan", email = "ccrisan@gmail.com"},