Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/ctrlpad/ChangeLog
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
0.01: New app - forked from widhid
0.02: Minor code improvements
0.03: Major code refactor
Change to a rounded style
Add a button for settings
Make buttons/toggles much more consistent and reliable
Instead of text, use icons instead
Make menu slightly wider to feel more spacious without losing the overlay feel
Change toggle styles/colors slightly
Migrate DnD functionality to global setting
Update toggle calls for speed and precision
12 changes: 9 additions & 3 deletions apps/ctrlpad/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Description

A control pad app to provide fast access to common functions, such as bluetooth power, HRM and Do Not Disturb.
By dragging from the top of the watch, you have this control without leaving your current app (e.g. on a run, bike ride or just watching the clock).
A control pad app to provide fast access to common functions, such as bluetooth power, HRM and Do Not Disturb, as well as shortcuts to load the clock, launcher, and settings app.

The app is designed to not conflict with other gestures - when the control pad is visible, it'll prevent propagation of events past it (touch, drag and swipe specifically). When the control pad is hidden, it'll ignore touch, drag and swipe events with the exception of an event dragging from the top 40 pixels of the screen.
By dragging from the top of the watch, you have this control without leaving your current app (e.g. on a run, bike ride or just watching the clock).

The app is designed to not conflict with other gestures - when the control pad is visible, it'll prevent propagation of events past it (touch, drag and swipe specifically). When the control pad is hidden, it'll ignore touch, drag and swipe events with the exception of an event dragging from the top 10 pixels of the screen. It's also designed to blend into the watch UI, with rounded borders, and neat design, and if you have `qmsched` installed, it will update that as well for a seamless experience.

# Usage

Expand All @@ -22,3 +22,9 @@ The control pad disables drag and touch event handlers while active, preventing

- Handle rotated screen (`g.setRotation(...)`)
- Handle notifications (sharing of `setLCDOverlay`)

## Creator
[bobrippling](https://github.com/bobrippling)

## Contributors
[RKBoss6](https://github.com/rkboss6)
239 changes: 137 additions & 102 deletions apps/ctrlpad/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,45 @@
}
var Overlay = (function () {
function Overlay() {
this.width = g.getWidth() - 10 * 2;
this.width = g.getWidth() - 2 * 2;
this.height = g.getHeight() - 24 - 10;
this.g2 = Graphics.createArrayBuffer(this.width, this.height, 4, { msb: true });
this.g2.transparent = 13;
this.renderG2();
}
Overlay.prototype.setBottom = function (bottom) {
var g2 = this.g2;
var y = bottom - this.height;
Bangle.setLCDOverlay(g2, 10, y - 10);
Bangle.setLCDOverlay(g2, 2, y - 10);
};
Overlay.prototype.hide = function () {
Bangle.setLCDOverlay();
};
Overlay.prototype.renderG2 = function () {
this.g2
.reset()
.setColor(g.theme.bg)
.fillRect(0, 0, this.width, this.height)
.setColor(colour.on.bg)
.drawRect(0, 0, this.width - 1, this.height - 1)
.drawRect(1, 1, this.width - 2, this.height - 2);
};
var border = 3;
this.g2
.reset()
.setColor(13)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the 13 all about here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah- it was just a number in the palette that's used as a transparent background color for the corners. It's the best solution I was able to come up with, but if you have another way, I would prefer having a more elegant solution over this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotya - I don't know of any way to set transparent otherwise. Do you have the source palette for us to see how likely it is that this will change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by source palette :( If it helps, in the screenshot before I edited the layers on, the transparent color was shown as an orange..

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I mean just where the 13 comes from, like where you found it - the image bytes themselves?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, like I said before, it's just a random number I chose, with no significance at all. That probably isn't the best way to go about something like that, but I didn't find another way :(
Do we need to change it/actually source the value from somewhere? It has worked for all I've been using it, you can upload the boot.js to your watch as well if you need to test it.
(I could still be misunderstanding what you're trying to ask, I'm sorry for the confusion)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see! I think 13 is ok then, let's make it clear in a comment that it's a somewhat arbitrary variable chosen out of nowhere in particular :)

.fillRect(0, 0, this.width, this.height) // Background (Transparent)

.setColor(colour.on.bg)
.fillRect({
x: 0,
y: 0,
w: this.width,
h: this.height,
r: 20
}) // The Outer Shape

.setColor(g.theme.bg)
.fillRect({
x: border,
y: border,
w: this.width - (border * 2),
h: this.height - (border * 2),
r: 16
});
};
return Overlay;
}());
var colour = {
Expand All @@ -46,7 +63,7 @@
},
off: {
fg: "#000",
bg: "#bbb",
bg: g.theme.dark?"#fff":"#bbb",
},
};
var Controls = (function () {
Expand All @@ -59,15 +76,24 @@
{ x: width / 4 - 10, y: centreY - circleGapY },
{ x: width / 2, y: centreY - circleGapY },
{ x: width * 3 / 4 + 10, y: centreY - circleGapY },
{ x: width / 3, y: centreY + circleGapY },
{ x: width * 2 / 3, y: centreY + circleGapY },
{ x: width / 4 - 10, y: centreY + circleGapY },
{ x: width / 2, y: centreY + circleGapY },
{ x: width * 3 / 4 + 10, y: centreY + circleGapY },
].map(function (xy, i) {
var ctrl = xy;
var from = controls[i];
ctrl.text = from.text;
ctrl.cb = from.cb;
Object.assign(ctrl, from.cb(false) ? colour.on : colour.off);
return ctrl;
var ctrl = xy;
var from = controls[i];

// Wrap into a cb function
ctrl.cb = function(tap) {
if (tap) return from.toggle();
return from.get();
};

ctrl.text = from.text;
ctrl.img = from.img;
Object.assign(ctrl, ctrl.cb(false) ? colour.on : colour.off);

return ctrl;
});
}
Controls.prototype.draw = function (g, single) {
Expand All @@ -80,7 +106,7 @@
.setColor(ctrl.bg)
.fillCircle(ctrl.x, ctrl.y, 23)
.setColor(ctrl.fg)
.drawString(ctrl.text, ctrl.x, ctrl.y);
.drawImage(ctrl.img,ctrl.x-12,ctrl.y-12)
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image is drawn at a fixed offset (ctrl.x-12, ctrl.y-12) which assumes all images are 24x24 pixels. If images of different sizes are used in the future, this could cause misalignment. Consider adding image width/height properties or centering logic.

Copilot uses AI. Check for mistakes.
}
};
Controls.prototype.hitTest = function (x, y) {
Expand Down Expand Up @@ -109,79 +135,71 @@
var initUI = function () {
if (ui)
return;
var controls = [
{
text: "BLE",
cb: function (tap) {
var on = NRF.getSecurityStatus().advertising;
if (tap) {
if (on)
NRF.sleep();
else
NRF.wake();
}
return on !== tap;
}
},
{
text: "DnD",
cb: function (tap) {
var on;
if ((on = !!origBuzz)) {
if (tap) {
Bangle.buzz = origBuzz;
origBuzz = undefined;
}
}
else {
if (tap) {
origBuzz = Bangle.buzz;
Bangle.buzz = function () { return Promise.resolve(); };
setTimeout(function () {
if (!origBuzz)
return;
Bangle.buzz = origBuzz;
origBuzz = undefined;
}, 1000 * 60 * 10);
}
}
return on !== tap;
}
},
{
text: "HRM",
cb: function (tap) {
var _a;
var id = "widhid";
var hrm = (_a = Bangle._PWR) === null || _a === void 0 ? void 0 : _a.HRM;
var off = !hrm || hrm.indexOf(id) === -1;
if (off) {
if (tap)
Bangle.setHRMPower(1, id);
}
else if (tap) {
Bangle.setHRMPower(0, id);
}
return !off !== tap;
}
},
{
text: "clk",
cb: function (tap) {
if (tap)
Bangle.showClock(), terminateUI();
return true;
},
},
{
text: "lch",
cb: function (tap) {
if (tap)
Bangle.showLauncher(), terminateUI();
return true;
},
},
];
// ... inside initUI ...
var controls = [
{
text: "BLE",
img:atob("GBiBAAAAAAAYAAAcAAAfAAAbgAAZ4AYYYAcY4AOZwAH/AAB+AAA8AAA8AAB+AAD/AAOZwAcY4A4YYAQZ4AAbgAAfAAAcAAAYAAAAAA=="),
get: () => {
const status = NRF.getSecurityStatus();
return status.advertising || status.connected;
},
toggle: () => {
if (NRF.getSecurityStatus().advertising||NRF.getSecurityStatus().connected) NRF.sleep(); else NRF.wake();
}
},
{
text: "DnD",
img:atob("GBiBAAAAAAAAAAA8AAAYAAAYAAD/AAH/gAH/gAP/wAP/wAP/wAP/wAP/wAP/wAP/wAf/4Af/4A//8B//+A//8AAAAAA8AAAAAAAAAA=="),
get: () => {
return require("Storage").readJSON("setting.json", 1).quiet === 1;
},
toggle: () => {
let s = require("Storage").readJSON("setting.json", 1);
s.quiet = s.quiet ? 0 : 1;
require("Storage").writeJSON("setting.json", s);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make this optional, I want the quiet mode to be temporary and also avoid the flash writes on each tap

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only gripe with that is when I first used the control panel, bluetooth wouldn't connect, and it took me a few days before I figured out that it was locally turning bluetooth off, and just wouldn't display that. I would prefer to have it global so all apps are affected but haptics still work, but possibly avoiding flash writes if we can.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a mix-up here? This is quiet mode, not bluetooth?

Copy link
Contributor Author

@RKBoss6 RKBoss6 Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I was using it as an example. The point was I didn't want any local change to quiet mode when there's already a global implementation. Could we have a global variable used in messages instead that is saved on e. Kill? I think that would be a better solution for system-level quiet mode, as it avoids writes unnecessarily until E.kill is triggered, and can just be appended in bootloader for simplicity.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see - my use case is different - I want a global change to just buzz but I don't want quiet mode installed or to have it as a hard dependency of the app. Perhaps have the code check if quiet mode scheduler or similar is installed and enable the functionality in that case?

And yes, agreed - saving quiet mode setting in the kill event handler sounds good

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! For the e.kill handler, we'll need a global variable to act in the file's place, which we have to create. Should we do that, and then update all the apps that use quiet mode? Alternatively, we could have a more system level one, and create a Bangle.quietMode variable for the system instead that achieves the same effect. @gfwilliams wdyt?

Copy link
Member

@gfwilliams gfwilliams Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like the idea of starting to mess with other apps and adding global variables just to deal with this case. So the reason you want to save is because the messages lib looks at quiet in setting.json?

It looks like you and @bobrippling want different things, and as he says maybe you need it to be an option.

Either:

  • You have a temporary change which you can do by just overwriting the .buzz function (although if quiet was set already in settings, you won't ever be able to unset it by changing .buzz)
  • You change the global setting by writing to setting.json which I think is the best bet - I wouldn't worry about the kill handler (as then you've got these two ways of disabling buzzing which are overlapping). You're only writing in response to a user input which you'll do at most a few times a day, so I don't think this is a big deal.

I think probably the best bet it to switch to just changing .quiet, and then if it's temporary (as bob would like) in a kill handler you set the setting back to what it was before.

Interestingly internally Bangle.js actually reads the settings file each time an app is loaded and looks at the value of vibrate, and if so disables JSBF_ENABLE_BUZZ which stops vibration regardless of what you do to .buzz or what quiet is set to. Really, longer term we should probably just expose the ability to set that flag and delete all the checks for .quiet.

I guess quiet was added rather than changing vibrate because someone still wanted the haptics even when quiet, but maybe we could have a Bangle.haptic that the built-in menus use, and then that gives us a bit more flexibility (to disable haptics as well as making them stronger/weaker).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good! As for this however, I can make it a setting and use that to decide whether we want buzzes at all, or only quiet mode for messages, etc. It might take some time though, as I'm a bit busy now, but I'll try to get it done soon.

//if qm widget is present, update that
if (typeof WIDGETS === "object" && "qmsched" in WIDGETS) WIDGETS["qmsched"].draw();
if (global.setAppQuietMode) setAppQuietMode(s.quiet);
}
},
{
text: "HRM",
img:atob("GBiBAAAAAA+B8B/D+D/n/H///v/////////P///P///H/3+WQAC2Xj82HB86uA/48Af54AP9wAH9gAD/AAB+AAA8AAAYAAAAAAAAAA=="),
get: () => Bangle.isHRMOn(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will mislead users - tapping the button will have no visible effect if another app/widget has enabled it (it'll remain on)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, how do you propose we go about fixing it? It has tripped me up when I used it, so is there a way to distinguish whether we triggered it, or if it's on in general? I suppose if we really want the 'control center feel' that we get on phones, it might be best to have it as it is now, as it just shows the state of the HRM and allows you to toggle...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is - we can either have a boolean to keep track if we triggered it, or we delve into Bangle internals to find out, which is what the code does atm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotya, let me look and see how it's done now to see if we can add that functionality in!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - the code had the checks in, you could pinch that:

const hrm = (Bangle as any)._PWR?.HRM as undefined | Array<string> ;
const off = !hrm || hrm.indexOf(id) === -1;

toggle: () => {
Bangle.setHRMPower(!Bangle.isHRMOn(), "widhid");
}
},
{
text: "clock",
img:atob("GBiBAAB+AAP/wAeB4A4AcBgAGDAADHAYDmAYBmAYBsAYA8AYA8AYA8AcA8AOA8AHA2ADBmAABnAADjAADBgAGA4AcAeB4AP/wAB+AA=="),
get: () => false, // Always looks "off" until pressed
toggle: () => {
Bangle.showClock();
return "close";
}
},
{
text: "launch",
img:atob("GBiBAAAAAAAAAAAAAA/AwB/gwBhgwBhn+Bhn+BhgwB/gwA/AwAAAAAAAAA/D8B/n+BhmGBhmGBhmGBhmGB/n+A/D8AAAAAAAAAAAAA=="),
get: () => false,
toggle: () => {
Bangle.showLauncher();
return "close";
}
},
{
text: "settings",
img:atob("GBiBAAA8AAB+AAR+IA7/cB//+D///B//+A/D8B8A+H8A/v4Af/4Af/4Af/4Af38A/h8A+A/D8B//+D///B//+A7/cAR+IAB+AAA8AA=="),
get: () => false,
toggle: () => {
Bangle.load("setting.app.js");
return "close";
}
}
];

var overlay = new Overlay();
ui = {
overlay: overlay,
Expand Down Expand Up @@ -218,7 +236,7 @@
break;
case 0:
if (e.b && !touchDown) {
if (e.y <= 40) {
if (e.y <= 10) {
state = 1;
startY = e.y;
(_a = E.stopEventPropagation) === null || _a === void 0 ? void 0 : _a.call(E);
Expand Down Expand Up @@ -289,6 +307,30 @@
if (e.b)
touchDown = true;
});
var onCtrlTap = function(ctrl) {
Bangle.buzz(20);

var result = ctrl.cb(true);
if (result === "close") {
terminateUI();
return;
}
Comment on lines +314 to +317
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok to have the callback terminate the UI itself, rather than spread the responsibility and allocate strings in the code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was due to ui being undefined when it's called, because it's defined later in scope, so this just passes it down to a place where it can actually terminate the UI first.

Copy link
Collaborator

@bobrippling bobrippling Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you show me the code where that's the problem and the error output? These functions are all executed after they're all defined, so I don't see how it can be a scoping problem.

For it being a problem that ui isn't set (not a scope problem, just logic), then:

var result = ctrl.cb(...); // if ui was `undefined` here then

if(result == "close"){
  terminateUI(); // there's no code between there and here which will make it defined
  return;
}

ui.ctrls.controls.forEach(function(c) {
var isActive = c.cb(false);
Object.assign(c, isActive ? colour.on : colour.off);
});

// Clear and Redraw the buffer
ui.overlay.renderG2();
ui.ctrls.draw(ui.overlay.g2);

// Force an update through the overlay
var y = g.getHeight() - ui.overlay.height;
Bangle.setLCDOverlay(ui.overlay.g2, 2, y - 10);
Bangle.buzz(10);

};

var onTouch = (function (_btn, xy) {
var _a;
if (!ui || !xy)
Expand All @@ -301,14 +343,7 @@
(_a = E.stopEventPropagation) === null || _a === void 0 ? void 0 : _a.call(E);
}
});
var origBuzz;
var onCtrlTap = function (ctrl, ui) {
Bangle.buzz(20);
var col = ctrl.cb(true) ? colour.on : colour.off;
ctrl.fg = col.fg;
ctrl.bg = col.bg;
ui.ctrls.draw(ui.overlay.g2, ctrl);
};

Bangle.prependListener("drag", onDrag);
Bangle.on("lock", terminateUI);
})();
Loading
Loading