@qtoggle/qui 1.20.0-alpha.7 → 1.20.0-beta.1

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.
@@ -1,11 +1,12 @@
1
1
 
2
- import {gettext} from '$qui/base/i18n.js'
3
- import {mix} from '$qui/base/mixwith.js'
4
- import StockIcon from '$qui/icons/stock-icon.js'
5
- import PageMixin from '$qui/pages/page.js'
6
- import * as Sections from '$qui/sections/sections.js'
7
- import * as ObjectUtils from '$qui/utils/object.js'
8
- import * as Window from '$qui/window.js'
2
+ import {gettext} from '$qui/base/i18n.js'
3
+ import {mix} from '$qui/base/mixwith.js'
4
+ import StockIcon from '$qui/icons/stock-icon.js'
5
+ import PageMixin from '$qui/pages/page.js'
6
+ import * as Sections from '$qui/sections/sections.js'
7
+ import * as ObjectUtils from '$qui/utils/object.js'
8
+ import * as PromiseUtils from '$qui/utils/promise.js'
9
+ import * as Window from '$qui/window.js'
9
10
 
10
11
  import Form from '../form.js'
11
12
 
@@ -59,7 +60,14 @@ class PageForm extends mix(Form).with(PageMixin) {
59
60
 
60
61
  handleBecomeCurrent() {
61
62
  super.handleBecomeCurrent()
62
- this._updateVertScroll()
63
+
64
+ /* Measured after the next paint, when layout is already up to date. Measuring now would lay out a freshly built
65
+ * form on the spot, and again after the classes set here. */
66
+ PromiseUtils.afterNextPaint().then(function () {
67
+ if (this.isCurrent()) {
68
+ this._updateVertScroll()
69
+ }
70
+ }.bind(this))
63
71
  }
64
72
 
65
73
  handleVertScroll() {
package/js/icons/icon.js CHANGED
@@ -1,4 +1,7 @@
1
1
 
2
+ import * as ObjectUtils from '$qui/utils/object.js'
3
+
4
+
2
5
  const ICON_DATA_ATTR = '_icon'
3
6
 
4
7
 
@@ -49,6 +52,22 @@ class Icon {
49
52
  }
50
53
  }
51
54
 
55
+ /**
56
+ * Tell if another icon would render the same as this one.
57
+ * @param {?qui.icons.Icon} other
58
+ * @returns {Boolean}
59
+ */
60
+ equals(other) {
61
+ if (other === this) {
62
+ return true
63
+ }
64
+ if (!other || other.constructor !== this.constructor) {
65
+ return false
66
+ }
67
+
68
+ return ObjectUtils.deepEquals(this.toAttributes(), other.toAttributes())
69
+ }
70
+
52
71
  /**
53
72
  * Return the icon that has been previously applied to an element.
54
73
  * @param {jQuery} element
@@ -45,9 +45,21 @@ class IconLabelListItem extends mix(ListItem).with(IconLabelViewMixin) {
45
45
  return false
46
46
  }
47
47
 
48
- this.setLabel(other.getLabel())
49
- this.setSubLabel(other.getSubLabel())
50
- this.setIcon(other.getIcon())
48
+ /* Updates arrive for every server event, mostly with nothing changed. Re-applying an icon rebuilds its elements
49
+ * with a fade and may sample its colors, so each part is only set when it differs. */
50
+ if (other.getLabel() !== this.getLabel()) {
51
+ this.setLabel(other.getLabel())
52
+ }
53
+ if (other.getSubLabel() !== this.getSubLabel()) {
54
+ this.setSubLabel(other.getSubLabel())
55
+ }
56
+
57
+ let icon = other.getIcon()
58
+ let currentIcon = this.getIcon()
59
+ let iconChanged = icon ? !icon.equals(currentIcon) : currentIcon != null
60
+ if (iconChanged) {
61
+ this.setIcon(icon)
62
+ }
51
63
 
52
64
  return true
53
65
  }
package/js/lists/list.js CHANGED
@@ -75,6 +75,10 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
75
75
  this._filterCollapseTimeout = null
76
76
  this._revealFrameHandle = null
77
77
  this._applySearchFilterDebouncer = new Debouncer(() => this._applySearchFilter(), SEARCH_FILTER_DELAY)
78
+
79
+ /* Counts clicks, so that only the last of several quick clicks gets handled */
80
+ this._selectionClickCount = 0
81
+ this._selectionChangeFromItems = null
78
82
  }
79
83
 
80
84
  makeHTML() {
@@ -370,24 +374,54 @@ class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin)
370
374
  return /* Selection unchanged */
371
375
  }
372
376
 
373
- let promise = this.onSelectionChange(oldItems, newItems) || Promise.resolve()
374
-
375
- promise.then(function () {
377
+ let setSelection = function (deselected, selected) {
376
378
  try {
377
- removedItems.forEach(i => i.setSelected(false))
378
- addedItems.forEach(i => i.setSelected(true))
379
+ deselected.forEach(i => i.setSelected(false))
380
+ selected.forEach(i => i.setSelected(true))
379
381
  }
380
382
  catch (e) {
381
383
  logger.errorStack('setSelected failed', e)
382
384
  }
383
- }).catch(function (e) {
385
+ }
386
+
387
+ /* Show the new selection first and let its fade finish before onSelectionChange(). That usually builds and
388
+ * pushes a whole page, which blocks the main thread and would otherwise hold back the tap feedback. */
389
+ setSelection(removedItems, addedItems)
390
+
391
+ /* Quick clicks in a row make one change, from the selection before the first one to that of the last one, so
392
+ * that no page gets built for a selection that is already gone */
393
+ if (this._selectionChangeFromItems == null) {
394
+ this._selectionChangeFromItems = oldItems
395
+ }
396
+ let clickCount = ++this._selectionClickCount
397
+ let fromItems = null
398
+
399
+ new Promise(resolve => window.requestAnimationFrame(resolve)).then(function () {
400
+ return Theme.afterTransitionPromise()
401
+ }).then(function () {
402
+ if (clickCount !== this._selectionClickCount) {
403
+ return /* A later click took over */
404
+ }
405
+
406
+ fromItems = this._selectionChangeFromItems
407
+ this._selectionChangeFromItems = null
408
+ if (fromItems.length === newItems.length && fromItems.every(i => newItems.includes(i))) {
409
+ return /* Clicked back to where it started */
410
+ }
411
+
412
+ return this.onSelectionChange(fromItems, newItems)
413
+ }.bind(this)).catch(function (e) {
414
+ if (fromItems && clickCount === this._selectionClickCount) {
415
+ setSelection(newItems, fromItems)
416
+ }
417
+
384
418
  if (e == null) {
385
419
  logger.debug('selection change rejected')
386
420
  }
387
421
  else {
388
422
  throw e
389
423
  }
390
- })
424
+ }.bind(this))
391
425
  }
392
426
 
393
427
  _handleLongPress(item) {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import $ from '$qui/lib/jquery.module.js'
6
6
 
7
+ import Icon from '$qui/icons/icon.js'
7
8
  import StockIcon from '$qui/icons/stock-icon.js'
8
9
  import * as Icons from '$qui/icons/icons.js'
9
10
  import * as Toast from '$qui/messages/toast.js'
@@ -60,8 +61,13 @@ let lastMessage = null
60
61
  * @param {String} [message] an optional status message
61
62
  */
62
63
  export function set(status, message = null) {
64
+ update(status, message)
65
+ }
66
+
67
+ function update(status, message, forceIcon = false) {
63
68
  if (statusLockTimeoutHandle) {
64
- pendingStatusParams = {status, message}
69
+ forceIcon = forceIcon || Boolean(pendingStatusParams && pendingStatusParams.forceIcon)
70
+ pendingStatusParams = {status, message, forceIcon}
65
71
  return
66
72
  }
67
73
 
@@ -78,7 +84,7 @@ export function set(status, message = null) {
78
84
 
79
85
  statusLockTimeoutHandle = null
80
86
  if (pendingStatusParams) {
81
- set(pendingStatusParams.status, pendingStatusParams.message)
87
+ update(pendingStatusParams.status, pendingStatusParams.message, pendingStatusParams.forceIcon)
82
88
  pendingStatusParams = null
83
89
  }
84
90
 
@@ -144,12 +150,19 @@ export function set(status, message = null) {
144
150
  variant = 'interactive'
145
151
  }
146
152
 
147
- new StockIcon({
153
+ let icon = new StockIcon({
148
154
  name: iconName,
149
155
  decoration: decoration,
150
156
  variant: variant,
151
157
  animation: animation
152
- }).applyTo(statusIndicator.find('.qui-icon'))
158
+ })
159
+
160
+ /* The status is set again after every server response, mostly unchanged, and rendering an icon rebuilds it.
161
+ * A theme change keeps the same icon attributes but not the colors they stand for, so it forces a render. */
162
+ let iconElement = statusIndicator.find('.qui-icon')
163
+ if (forceIcon || !icon.equals(Icon.getFromElement(iconElement))) {
164
+ icon.applyTo(iconElement)
165
+ }
153
166
  }
154
167
 
155
168
  if (message) {
@@ -183,7 +196,7 @@ export function init() {
183
196
  })
184
197
 
185
198
  Theme.changeSignal.connect(function () {
186
- set(lastStatus, lastMessage)
199
+ update(lastStatus, lastMessage, /* forceIcon = */ true)
187
200
  })
188
201
 
189
202
  set(STATUS_OK)
@@ -32,6 +32,21 @@ export function later(timeout, arg) {
32
32
  })
33
33
  }
34
34
 
35
+ /**
36
+ * Return a promise that resolves once the next frame has been painted, when reading element sizes is cheap because
37
+ * layout is already up to date.
38
+ * @alias qui.utils.promise.afterNextPaint
39
+ * @returns {Promise}
40
+ */
41
+ export function afterNextPaint() {
42
+ /* A requestAnimationFrame() callback runs before that frame's layout; a timeout set from it runs after the paint */
43
+ return new Promise(function (resolve) {
44
+ window.requestAnimationFrame(function () {
45
+ setTimeout(resolve, 0)
46
+ })
47
+ })
48
+ }
49
+
35
50
  /**
36
51
  * Run a promise with a timeout. If the promise is not fulfilled within the given timeout, the returned promise will be
37
52
  * rejected with {@link qui.base.errors.TimeoutError}
package/less/base.less CHANGED
@@ -57,6 +57,7 @@ a:ACTIVE {
57
57
  /* Root elements */
58
58
 
59
59
  html {
60
+ color-scheme: @color-scheme;
60
61
  color: @foreground-color;
61
62
  background: @background-root-color;
62
63
  height: 100%;
@@ -58,14 +58,40 @@ div.qui-icon-label-view {
58
58
  background: @background-color;
59
59
  color: @interactive-color;
60
60
 
61
- &.active {
61
+ /* The pressed shade is an overlay faded by opacity, which the compositor animates without repainting the row. A
62
+ * background transition repaints it on every frame, and slow devices feel that as tap lag. */
63
+ position: relative;
64
+
65
+ &::before {
66
+ content: '';
67
+ position: absolute;
68
+ top: 0;
69
+ right: 0;
70
+ bottom: 0;
71
+ left: 0;
62
72
  background: @background-active-color;
73
+ opacity: 0;
74
+ transition: opacity @transition-duration ease;
75
+ pointer-events: none;
76
+ }
77
+
78
+ /* Keeps the icon and labels above the overlay */
79
+ & > * {
80
+ position: relative;
81
+ }
82
+
83
+ &.active::before {
84
+ opacity: 1;
63
85
  }
64
86
 
65
87
  &.selected {
66
88
  background: @interactive-color;
67
89
  color: @background-color;
68
90
 
91
+ &::before {
92
+ background: @interactive-active-color;
93
+ }
94
+
69
95
  & > div.labels > div.sub-label {
70
96
  color: @background-color;
71
97
  background-color: fade(@interactive-active-color, 50%);
@@ -73,11 +99,6 @@ div.qui-icon-label-view {
73
99
 
74
100
  }
75
101
 
76
- &.active.selected {
77
- background: @interactive-active-color;
78
- color: @background-color;
79
- }
80
-
81
102
  }
82
103
 
83
104
  }
@@ -59,6 +59,18 @@ body.small-screen > div.qui-global-glass > div.qui-global-glass-container > div.
59
59
 
60
60
  }
61
61
 
62
+ /* On a large screen, pages fade in place instead of sliding, since animating left and width lays out every visible
63
+ * page on each frame. A page going away keeps its place until it has faded out; one coming in takes its place at once. */
64
+ body:NOT(.small-screen) > div.qui-main-container > div.qui-pages-container > div.qui-page {
65
+ transition: opacity @transition-duration ease-out,
66
+ width 0s linear @transition-duration,
67
+ left 0s linear @transition-duration;
68
+
69
+ &.visible {
70
+ transition: opacity @transition-duration ease-out;
71
+ }
72
+ }
73
+
62
74
  div.qui-page.current {
63
75
  z-index: 9;
64
76
  }
@@ -4,6 +4,9 @@
4
4
 
5
5
  @theme-name: dark;
6
6
 
7
+ /* Declared so that browsers and WebViews do not apply their own automatic dark mode on top of the theme */
8
+ @color-scheme: only dark;
9
+
7
10
 
8
11
  /* fonts */
9
12
 
@@ -4,6 +4,9 @@
4
4
 
5
5
  @theme-name: light;
6
6
 
7
+ /* Declared so that browsers and WebViews do not apply their own automatic dark mode on top of the theme */
8
+ @color-scheme: only light;
9
+
7
10
 
8
11
  /* fonts */
9
12
 
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.7",
4
+ "version": "1.20.0-beta.1",
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.7"
3
+ version = "1.20.0-beta.1"
4
4
  description = "A fully fledged qToggle implementation written in Python"
5
5
  authors = [
6
6
  {name = "Calin Crisan", email = "ccrisan@gmail.com"},