Skip to content

Commit

Permalink
Merge pull request #2953 from thyttan/ui-slider-lib
Browse files Browse the repository at this point in the history
[Slider] New library for input via sliders
  • Loading branch information
gfwilliams authored Oct 19, 2023
2 parents fd750b1 + 71a445c commit 9da07b3
Show file tree
Hide file tree
Showing 2 changed files with 339 additions and 0 deletions.
233 changes: 233 additions & 0 deletions modules/Slider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/* Copyright (c) 2023 Bangle.js contributors. See the file LICENSE for copying permission. */

// At time of writing in October 2023 this module is new and things are more likely to change during the coming weeks than in a month or two.

// See Slider.md for documentation

/* Minify to 'Slider.min.js' by: // TODO: Should we do this for Slider module?
* checking out: https://github.com/espruino/EspruinoDocs
* run: ../EspruinoDocs/bin/minify.js modules/Slider.js modules/Slider.min.js
*/

exports.create = function(cb, conf) {

const R = Bangle.appRect;

// Empty function added to cb if it's undefined.
if (!cb) cb = ()=>{};

let o = {};
o.v = {}; // variables go here.
o.f = {}; // functions go here.

// Default configuration for the indicator, modified by parameter `conf`:
o.c = Object.assign({ // constants go here.
initLevel:0,
horizontal:false,
xStart:R.x2-R.w/4-4,
width:R.w/4,
yStart:R.y+4,
height:R.h-10,
steps:30,

dragableSlider:true,
dragRect:R,
mode:"incr",
oversizeR:0,
oversizeL:0,
propagateDrag:false,
timeout:1,

drawableSlider:true,
colorFG:g.theme.fg2,
colorBG:g.theme.bg2,
rounded:true,
outerBorderSize:Math.round(2*R.w/176), // 176 is the # of pixels in a row on the Bangle.js 2's screen and typically also its app rectangles, used here to rescale to whatever pixel count is on the current app rectangle.
innerBorderSize:Math.round(2*R.w/176),

autoProgress:false,
},conf);

// If borders are bigger than the configured width, make them smaller to avoid glitches.
while (o.c.width <= 2*(o.c.outerBorderSize+o.c.innerBorderSize)) {
o.c.outerBorderSize--;
o.c.innerBorderSize--;
}
o.c.outerBorderSize = Math.max(0,o.c.outerBorderSize);
o.c.innerBorderSize = Math.max(0,o.c.innerBorderSize);

let totalBorderSize = o.c.outerBorderSize + o.c.innerBorderSize;
o.c.rounded = o.c.rounded?o.c.width/2:0;
if (o.c.rounded) o.c._rounded = (o.c.width-2*totalBorderSize)/2;

o.c.STEP_SIZE = ((o.c.height-2*totalBorderSize)-(!o.c.rounded?0:(2*o.c._rounded)))/o.c.steps;

// If horizontal, flip things around.
if (o.c.horizontal) {
let mediator = o.c.xStart;
o.c.xStart = o.c.yStart;
o.c.yStart = mediator;
mediator = o.c.width;
o.c.width = o.c.height;
o.c.height = mediator;
delete mediator;
}

// Make room for the border. Underscore indicates the area for the actual indicator bar without borders.
o.c._xStart = o.c.xStart + totalBorderSize;
o.c._width = o.c.width - 2*totalBorderSize;
o.c._yStart = o.c.yStart + totalBorderSize;
o.c._height = o.c.height - 2*totalBorderSize;

// Add a rectangle object with x, y, x2, y2, w and h values.
o.c.r = {x:o.c.xStart, y:o.c.yStart, x2:o.c.xStart+o.c.width, y2:o.c.yStart+o.c.height, w:o.c.width, h:o.c.height};

// Initialize the level
o.v.level = o.c.initLevel;

// Only add interactivity if wanted.
if (o.c.dragableSlider) {

let useMap = (o.c.mode==="map"||o.c.mode==="mapincr")?true:false;
let useIncr = (o.c.mode==="incr"||o.c.mode==="mapincr")?true:false;

const Y_MAX = g.getHeight()-1; // TODO: Should this take users screen calibration into account?

o.v.ebLast = 0;
o.v.dy = 0;

o.f.wasOnDragRect = (exFirst, eyFirst)=>{
"ram";
return exFirst>o.c.dragRect.x && exFirst<o.c.dragRect.x2 && eyFirst>o.c.dragRect.y && eyFirst<o.c.dragRect.y2;
};

o.f.wasOnIndicator = (exFirst)=>{
"ram";
if (!o.c.horizontal) return exFirst>o.c._xStart-o.c.oversizeL*o.c._width && exFirst<o.c._xStart+o.c._width+o.c.oversizeR*o.c._width;
if (o.c.horizontal) return exFirst>o.c._yStart-o.c.oversizeL*o.c._height && exFirst<o.c._yStart+o.c._height+o.c.oversizeR*o.c._height;
};

// Function to pass to `Bangle.on('drag', )`
o.f.dragSlider = e=>{
"ram";
if (o.v.ebLast==0) {
exFirst = o.c.horizontal?e.y:e.x;
eyFirst = o.c.horizontal?e.x:e.y;
}

// Only react if on allowed area.
if (o.f.wasOnDragRect(exFirst, eyFirst)) {
o.v.dragActive = true;
if (!o.c.propagateDrag) E.stopEventPropagation&&E.stopEventPropagation();

if (o.v.timeoutID) {clearTimeout(o.v.timeoutID); o.v.timeoutID = undefined;}
if (e.b==0 && !o.v.timeoutID && (o.c.timeout || o.c.timeout===0)) o.v.timeoutID = setTimeout(o.f.remove, 1000*o.c.timeout);

if (useMap && o.f.wasOnIndicator(exFirst)) { // If draging starts on the indicator, adjust one-to-one.

let input = !o.c.horizontal?
Math.min((Y_MAX-e.y)-o.c.yStart-3*o.c.rounded/4, o.c.height):
Math.min(e.x-o.c.xStart-3*o.c.rounded/4, o.c.width);
input = Math.round(input/o.c.STEP_SIZE);

o.v.level = Math.min(Math.max(input,0),o.c.steps);

o.v.cbObj = {mode:"map", value:o.v.level};

} else if (useIncr) { // Heavily inspired by "updown" mode of setUI.

o.v.dy += o.c.horizontal?-e.dx:e.dy;
//if (!e.b) o.v.dy=0;

while (Math.abs(o.v.dy)>32) {
let incr;
if (o.v.dy>0) { o.v.dy-=32; incr = 1;}
else { o.v.dy+=32; incr = -1;}
Bangle.buzz(20);

o.v.level = Math.min(Math.max(o.v.level-incr,0),o.c.steps);

o.v.cbObj = {mode:"incr", value:incr};
}
}
if (o.v.cbObj && (o.v.level!==o.v.prevLevel||o.v.level===0||o.v.level===o.c.steps)) {
cb(o.v.cbObj.mode, o.v.cbObj.value);
o.f.draw&&o.f.draw(o.v.level);
}
o.v.cbObj = null;
o.v.prevLevel = o.v.level;
o.v.ebLast = e.b;
}
};

// Cleanup.
o.f.remove = ()=> {
Bangle.removeListener('drag', o.f.dragSlider);
o.v.dragActive = false;
o.v.timeoutID = undefined;
cb("remove", o.v.level);
};
}

// Add standard slider graphics only if wanted.
if (o.c.drawableSlider) {

// Function for getting the indication bars size.
o.f.updateBar = (levelHeight)=>{
"ram";
if (!o.c.horizontal) return {x:o.c._xStart,y:o.c._yStart+o.c._height-levelHeight,w:o.c._width,y2:o.c._yStart+o.c._height,r:o.c.rounded};
if (o.c.horizontal) return {x:o.c._xStart,y:o.c._yStart,w:levelHeight,h:o.c._height,r:o.c.rounded};
};

o.c.borderRect = {x:o.c._xStart-totalBorderSize,y:o.c._yStart-totalBorderSize,w:o.c._width+2*totalBorderSize,h:o.c._height+2*totalBorderSize,r:o.c.rounded};

o.c.hollowRect = {x:o.c._xStart-o.c.innerBorderSize,y:o.c._yStart-o.c.innerBorderSize,w:o.c._width+2*o.c.innerBorderSize,h:o.c._height+2*o.c.innerBorderSize,r:o.c.rounded};

// Standard slider drawing method.
o.f.draw = (level)=>{
"ram";

g.setColor(o.c.colorFG).fillRect(o.c.borderRect). // To get outer border...
setColor(o.c.colorBG).fillRect(o.c.hollowRect). // ... and here it's made hollow.
setColor(0==level?o.c.colorBG:o.c.colorFG).fillRect(o.f.updateBar((!o.c.rounded?0:(2*o.c._rounded))+level*o.c.STEP_SIZE)); // Here the bar is drawn.
if (o.c.rounded && level===0) { // Hollow circle indicates level zero when slider is rounded.
g.setColor(o.c.colorFG).fillCircle(o.c._xStart+o.c._rounded, o.c._yStart+o.c._height-o.c._rounded, o.c._rounded).
setColor(o.c.colorBG).fillCircle(o.c._xStart+o.c._rounded, o.c._yStart+o.c._height-o.c._rounded, o.c._rounded-o.c.outerBorderSize);
}
};
}

// Add logic for auto progressing the slider only if wanted.
if (o.c.autoProgress) {
o.f.autoUpdate = ()=>{
o.v.level = o.v.autoInitLevel + Math.round((Date.now()-o.v.autoInitTime)/1000);
if (o.v.level>o.c.steps) o.v.level=o.c.steps;
cb("auto", o.v.level);
o.f.draw&&o.f.draw(o.v.level);
if (o.v.level==o.c.steps) {o.f.stopAutoUpdate();}
};
o.f.initAutoValues = ()=>{
o.v.autoInitTime=Date.now();
o.v.autoInitLevel=o.v.level;
};
o.f.startAutoUpdate = (intervalSeconds)=>{
if (!intervalSeconds) intervalSeconds = 1;
o.f.stopAutoUpdate();
o.f.initAutoValues();
o.f.draw&&o.f.draw(o.v.level);
o.v.autoIntervalID = setInterval(o.f.autoUpdate,1000*intervalSeconds);
};
o.f.stopAutoUpdate = ()=>{
if (o.v.autoIntervalID) {
clearInterval(o.v.autoIntervalID);
o.v.autoIntervalID = undefined;
}
o.v.autoInitLevel = undefined;
o.v.autoInitTime = undefined;
};
}

return o;
};
106 changes: 106 additions & 0 deletions modules/Slider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
Slider Library
==============

*At time of writing in October 2023 this module is new and things are more likely to change during the coming weeks than in a month or two.*

> Take a look at README.md for hints on developing with this library.
Usage
-----

```js
var Slider = require("Slider");
var slider = Slider(callbackFunction, configObject);

Bangle.on("drag", slider.f.dragSlider);

// If the slider should take precedent over other drag handlers use (fw2v18 and up):
// Bangle.prependListener("drag", slider.f.dragSlider);
```

`callbackFunction` (`cb`) (first argument) determines what `slider` is used for. `slider` will pass two arguments, `mode` and `feedback` (`fb`), into `callbackFunction` (if `slider` is interactive or auto progressing). The different `mode`/`feedback` combinations to expect are:
- `"map", o.v.level` | current level when interacting by mapping interface.
- `"incr", incr` | where `incr` == +/-1, when interacting by incrementing interface.
- `"remove", o.v.level` | last level when the slider times out.
- `"auto", o.v.level` | when auto progressing.

`configObject` (`conf`) (second argument, optional) has the following defaults:

```js
R = Bangle.appRect; // For use when determining defaults below.

{
initLevel: 0, // The level to initialize the slider with.
horizontal: false, // Slider should be horizontal?
xStart: R.x2-R.w/4-4, // Leftmost x-coordinate. (Uppermost y-coordinate if horizontal)
width: R.w/4, // Width of the slider. (Height if horizontal)
yStart: R.y+4, // Uppermost y-coordinate. (Rightmost x-coordinate if horizontal)
height: R.h-10, // Height of the slider. (Width if horizontal)
steps: 30, // Number of discrete steps of the slider.

dragableSlider: true, // Should supply the sliders standard interaction mechanisms?
dragRect: R, // Accept input within this rectangle.
mode: "incr", // What mode of draging to use: "map", "incr" or "mapincr".
oversizeR: 0, // Determines if the mapping area should be extend outside the indicator (Right/Up).
oversizeL: 0, // Determines if the mapping area should be extend outside the indicator (Left/Down).
propagateDrag: false, // Pass the drag event on down the handler chain?
timeout: 1, // Seconds until the slider times out. If set to `false` the slider stays active. The callback function is responsible for repainting over the slider graphics.

drawableSlider: true, // Should supply the sliders standard drawing mechanism?
colorFG: g.theme.fg2, // Foreground color.
colorBG: g.theme.bg2, // Background color.
rounded: true, // Slider should have rounded corners?
outerBorderSize: Math.round(2*R.w/176), // The size of the visual border. Scaled in relation to Bangle.js 2 screen width/typical app rectangle widths.
innerBorderSize: Math.round(2*R.w/176), // The distance between visual border and the slider.

autoProgress: false, // The slider should be able to progress automatically?
}
```

A slider initiated in the Web IDE terminal window reveals its internals to a degree:
```js
slider = require("Slider").create(()=>{}, {autoProgress:true})
={
v: { level: 0, ebLast: 0, dy: 0 },
f: {
wasOnDragRect: function (exFirst,eyFirst) { ... }, // Used internally.
wasOnIndicator: function (exFirst) { ... }, // Used internally.
dragSlider: function (e) { ... }, // The drag handler.
remove: function () { ... }, // Used to remove the drag handler and run the callback function.
updateBar: function (levelHeight) { ... }, // Used internally to get the variable height rectangle for the indicator.
draw: function (level) { ... }, // Draw the slider with the supplied level.
autoUpdate: function () { ... }, // Used to update the slider when auto progressing.
initAutoValues: function () { ... }, // Used internally.
startAutoUpdate: function (intervalSeconds) { ... }, // `intervalSeconds` defaults to 1 second if it's not supplied when `startAutoUpdate` is called.
stopAutoUpdate: function () { ... } // Stop auto progressing and clear some related values.
},
c: { initLevel: 0, horizontal: false, xStart: 127, width: 44,
yStart: 4, height: 166, steps: 30, dragableSlider: true,
dragRect: { x: 0, y: 0, w: 176, h: 176,
x2: 175, y2: 175 },
mode: "incr",
oversizeR: 0, oversizeL: 0, propagateDrag: false, timeout: 1, drawableSlider: true,
colorFG: 63488, colorBG: 8, rounded: 22, outerBorderSize: 2, innerBorderSize: 2,
autoProgress: true, _rounded: 18, STEP_SIZE: 4.06666666666, _xStart: 131, _width: 36,
_yStart: 8, _height: 158,
r: { x: 127, y: 4, x2: 171, y2: 170,
w: 44, h: 166 },
borderRect: { x: 127, y: 4, w: 44, h: 166,
r: 22 },
hollowRect: { x: 129, y: 6, w: 40, h: 162,
r: 22 }
}
}
>
```
Tips
----

You can implement custom graphics for a slider in the `callbackFunction`. The slider test app mentioned in the links below do this. To draw on top of the included slider graphics you need to wrap the drawing code in a timeout somewhat like so: `setTimeout(drawingFunction,0,fb)` (see [`setTimeout` documentation](https://www.espruino.com/Reference#l__global_setTimeout)).

This comment has been minimized.

Copy link
@thyttan

thyttan Oct 21, 2023

Collaborator

@gfwilliams, a question regarding this:

The setTimeout here could be made unnecessary if we run the callback after drawing. The current order of first callback then draw is chosen to ape how e.g. menus do it, and to let callbacks run ASAP.

But I more and more get the feeling it would be smarter to swap it around to first draw then do callback, avoiding the need for setTimeout.

What do you think? (I realize this is splitting hairs...)

This comment has been minimized.

Copy link
@gfwilliams

gfwilliams Oct 25, 2023

Author Member

I guess the question is: Do you think that the code that runs in the callback might want to change the value that's in the slider before it redraws (eg to 'snap' to predefined values)?

I guess that's why you'd want to call it before rather than after, but if you don't think that's going to be needed, call it after? I haven't looked at your code but I can imagine it might be possible that changing the slider value in the callback will actually stop it working properly anyway, in which case it should definitely be after :)


Links
-----

There is a [slider test app on thyttan's personal app loader](https://thyttan.github.io/BangleApps/?q=slidertest) (at time of writing). Looking at [its code](https://github.com/thyttan/BangleApps/blob/ui-slider-lib/apps/slidertest/app.js) is a good way to see how the slider is used in app development.

The version of [Remote for Spotify on thyttan's personal app loader](https://thyttan.github.io/BangleApps/?q=spotrem) (at time of writing) also utilizes the `Slider` module. Here is [the code](https://github.com/thyttan/BangleApps/blob/ui-slider-lib/apps/spotrem/app.js).

0 comments on commit 9da07b3

Please sign in to comment.