Skip to content

Commit dccab43

Browse files
Color Timer Clock: add notifications, next-color swatch, per-card refresh, timezone widget
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 44e38af commit dccab43

8 files changed

Lines changed: 949 additions & 97 deletions

File tree

cinnamon-color-timer-clock-desklet@curbsoftware/README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,52 @@
11
# Color Timer Clock
22

3-
A Cinnamon desklet with three cards **Clock**, **Timer** and **Chronometer** whose background colors follow a schedule you define. Each card's color ramps between (time, color) stops: the clock by time of day wrapping over midnight, the timer by time remaining, the chronometer by time elapsed.
3+
A Cinnamon desklet with three cards (**Clock**, **Timer** and **Chronometer**) whose background colors follow a schedule you define. Each card's color ramps between (time, color) stops: the clock by time of day wrapping over midnight, the timer by time remaining, the chronometer by time elapsed.
44

55
![Color Timer Clock desklet](screenshot.png)
66

7+
## Why this exists
8+
9+
The color is not decoration. It's the signal. Each card has a job:
10+
11+
- The **clock** shows when AI providers run their low-traffic discounts. Set the color stops to match the window, and you know at a glance when it's cheap to run.
12+
- The **timer** counts down to credit resets, so I know when my API credits come back.
13+
- The **chronometer** runs sprints. Most of the work happens in the last stretch before a deadline, so the color tells me where I am in the sprint: green for the first half hour, amber sliding to red over the next hour and a half, blue once two hours are up. Blue means take a break.
14+
15+
One glance tells me the state of things. That's the whole idea.
16+
717
## Features
818

919
- Up to three side-by-side cards; show or hide each one
1020
- Per-card color schedules with smooth (interpolated) or stepped transitions
1121
- Clock card with an optional IANA timezone
1222
- Timer with on-card play/pause, reset and ±60 s buttons; survives restarts and expires correctly even after downtime
23+
- Optional desktop notification when the timer finishes
24+
- Optional notifications when the clock or chronometer reaches a schedule stop
1325
- Chronometer with pause/resume; the accumulated time survives restarts, and an optional hundredths display (20 updates/s while running)
1426
- Text and border colors adapt to the card background for readability (WCAG contrast ratio)
1527
- Tooltips on every card control
28+
- A per-card refresh button to reload settings without interrupting a running timer or chronometer
29+
- A next-colour swatch on each card that previews the upcoming schedule colour
1630
- One-click "Reset all schedules to defaults"
1731

1832
## Configuration
1933

2034
Right-click the desklet → **Configure…**
2135

22-
- **Color schedules** — a list of (time, color) stops per card. Colors are hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or a name (`red`, `blue`, …). Duplicate times keep the last row; invalid rows are dropped with a log line
23-
- **Smooth color transitions** — blend gradually between stops, or hold each color and jump at the next stop
24-
- **Timer minutes / seconds** — the default duration the timer restarts from
25-
- **Show hundredths of a second** — adds a `.ss` fraction to the chronometer while it runs
26-
- **Time / date format**`strftime` patterns for the clock card
27-
- **Maximum font sizes, card spacing, desklet width / height** — cards shrink text to fit
36+
- **Color schedules**: a list of (time, color) stops per card. Colors are hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or a name (`red`, `blue`, …). Duplicate times keep the last row; invalid rows are dropped with a log line
37+
- **Smooth color transitions**: blend gradually between stops, or hold each color and jump at the next stop
38+
- **Show a notification when the timer finishes**: pops a desktop notification when the countdown reaches zero
39+
- **Notify (per schedule row)**: tick a clock or chronometer schedule row's Notify checkbox to pop a notification when that time is reached
40+
- **Timer minutes / seconds**: the default duration the timer restarts from
41+
- **Refresh (per card)**: the small button in each card's top-right corner reloads that card's settings without restarting a running timer or chronometer
42+
- **Next-colour swatch (per card)**: the small circle in each card's bottom-right corner previews the next schedule colour and snaps to it at the breakpoint
43+
- **Show hundredths of a second**: adds a `.ss` fraction to the chronometer while it runs
44+
- **Time / date format**: `strftime` patterns for the clock card
45+
- **Maximum font sizes, card spacing, desklet width / height**: cards shrink text to fit
2846

2947
## Notes
3048

31-
Colors interpolate piecewise-linearly in RGB, so a red→blue ramp passes through purple. The clock schedule wraps over midnight; timer and chronometer schedules hold the last stop's color beyond it. Cards share the desklet width and each needs about 130 px — when the width cannot fit them all, the chronometer and then the timer hide automatically (a note is logged) and return when the desklet is widened.
49+
Colors interpolate piecewise-linearly in RGB, so a red→blue ramp passes through purple. The clock schedule wraps over midnight; timer and chronometer schedules hold the last stop's color beyond it. Cards share the desklet width and each needs about 130 px. When the width cannot fit them all, the chronometer and then the timer hide automatically (a note is logged) and return when the desklet is widened.
3250

3351
UUID: `cinnamon-color-timer-clock-desklet@curbsoftware`
3452

cinnamon-color-timer-clock-desklet@curbsoftware/files/cinnamon-color-timer-clock-desklet@curbsoftware/cardActions.js

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function rgbaToCss(rgba) {
128128
* rgbaToKey:
129129
* @rgba (array): [r, g, b, a] with a in 0..1
130130
*
131-
* Returns (string): "#rrggbbaa" (lowercase) a canonical key for change
131+
* Returns (string): "#rrggbbaa" (lowercase), a canonical key for change
132132
* detection, so a colour that round-trips identically does not trigger a
133133
* restyle.
134134
*/
@@ -181,7 +181,8 @@ function _rowSeconds(row) {
181181
* does not parse (counting them), sorts ascending and, when two rows share
182182
* a time, keeps the later row.
183183
*
184-
* Returns (object): { stops: [{t, rgba}], dropped }.
184+
* Returns (object): { stops: [{t, rgba, notify?}], dropped }. A stop carries
185+
* notify: true only when its row's notify flag was set.
185186
*/
186187
function normalizeSchedule(rows, maxT) {
187188
if (!Array.isArray(rows))
@@ -202,11 +203,14 @@ function normalizeSchedule(rows, maxT) {
202203
dropped++;
203204
continue;
204205
}
205-
tagged.push({
206+
let stop = {
206207
t: Math.max(0, Math.min(limit, Math.round(t))),
207208
rgba: parsed.rgba,
208209
i: i
209-
});
210+
};
211+
if (rows[i] && rows[i].notify)
212+
stop.notify = true;
213+
tagged.push(stop);
210214
}
211215

212216
/* Sort by time, then input order, so equal times keep the later row
@@ -219,12 +223,97 @@ function normalizeSchedule(rows, maxT) {
219223
for (let i = 0; i < tagged.length; i++) {
220224
if (i + 1 < tagged.length && tagged[i + 1].t === tagged[i].t)
221225
continue; /* equal time: the later row wins */
222-
stops.push({ t: tagged[i].t, rgba: tagged[i].rgba });
226+
let stop = { t: tagged[i].t, rgba: tagged[i].rgba };
227+
if (tagged[i].notify)
228+
stop.notify = true;
229+
stops.push(stop);
223230
}
224231

225232
return { stops: stops, dropped: dropped };
226233
}
227234

235+
/**
236+
* thresholdsCrossed:
237+
* @prev (number): previous sampled position
238+
* @cur (number): current sampled position
239+
* @times (array): sorted threshold times
240+
* @wrap (boolean): treat the position as circular (clock, rolls over at 0)
241+
*
242+
* Returns (array): thresholds crossed moving from @prev to @cur. A monotonic
243+
* position crosses prev < t <= cur; a wrapped position that went backwards
244+
* (prev > cur) crosses everything above prev and at or below cur.
245+
*/
246+
function thresholdsCrossed(prev, cur, times, wrap) {
247+
let out = [];
248+
if (!Array.isArray(times) || !Number.isFinite(prev) || !Number.isFinite(cur))
249+
return out;
250+
for (let i = 0; i < times.length; i++) {
251+
let t = Number(times[i]);
252+
if (!Number.isFinite(t))
253+
continue;
254+
let crossed = (wrap && prev > cur)
255+
? (t > prev || t <= cur)
256+
: (prev < t && t <= cur);
257+
if (crossed)
258+
out.push(t);
259+
}
260+
return out;
261+
}
262+
263+
/**
264+
* nextColor:
265+
* @stops (array): normalised stops [{t, rgba}], any order
266+
* @pos (number): query position in seconds
267+
* @opts (object): { wrap, reverse }. wrap treats the position as circular
268+
* (clock); reverse walks the schedule backwards (timer counting down).
269+
*
270+
* Returns (array): the colour of the next stop in the direction of travel, or
271+
* null when there are no stops. A forward schedule returns the first stop
272+
* above @pos (wrapping to the first past the end when @opts.wrap, else holding
273+
* the last stop); a reverse schedule returns the first stop below @pos,
274+
* falling back to the lowest stop at or past the end.
275+
*/
276+
function nextColor(stops, pos, opts) {
277+
opts = opts || {};
278+
279+
let list = [];
280+
if (Array.isArray(stops)) {
281+
for (let i = 0; i < stops.length; i++) {
282+
let s = stops[i];
283+
if (s && Number.isFinite(s.t) && Array.isArray(s.rgba))
284+
list.push({ t: s.t, rgba: s.rgba });
285+
}
286+
}
287+
if (list.length === 0)
288+
return null;
289+
list.sort(function (a, b) { return a.t - b.t; });
290+
291+
let q = Number(pos);
292+
if (!Number.isFinite(q))
293+
q = 0;
294+
if (opts.wrap)
295+
q = ((q % DAY_SECONDS) + DAY_SECONDS) % DAY_SECONDS;
296+
297+
if (list.length === 1)
298+
return list[0].rgba.slice();
299+
300+
if (opts.reverse) {
301+
for (let i = list.length - 1; i >= 0; i--) {
302+
if (list[i].t < q)
303+
return list[i].rgba.slice();
304+
}
305+
return list[0].rgba.slice();
306+
}
307+
308+
for (let i = 0; i < list.length; i++) {
309+
if (list[i].t > q)
310+
return list[i].rgba.slice();
311+
}
312+
if (opts.wrap)
313+
return list[0].rgba.slice();
314+
return list[list.length - 1].rgba.slice();
315+
}
316+
228317
/* ------------------------------------------------------------------ *
229318
* Schedule evaluation
230319
* ------------------------------------------------------------------ */
@@ -575,25 +664,66 @@ function computeCardInnerSize(deskletWidth, deskletHeight, rows, cols, cardSpaci
575664
};
576665
}
577666

667+
function _glyphEm(ch, em) {
668+
if (ch >= "0" && ch <= "9")
669+
return Math.max(em, 0.72);
670+
if (ch === ":" || ch === ".")
671+
return 0.38;
672+
if (ch === " " || ch === ",")
673+
return 0.33;
674+
return em;
675+
}
676+
677+
function _textEmUnits(text, em) {
678+
let s = String(text || "");
679+
let units = 0;
680+
for (let i = 0; i < s.length; i++)
681+
units += _glyphEm(s[i], em);
682+
return units;
683+
}
684+
578685
function _textWidthPx(text, sizePt, ptToPx, em) {
579-
let n = String(text || "").length;
580-
if (n === 0)
686+
let units = _textEmUnits(text, em);
687+
if (units <= 0)
581688
return 0;
582-
return n * sizePt * ptToPx * em;
689+
return units * sizePt * ptToPx;
583690
}
584691

585692
function _maxPtForWidth(text, innerW, ptToPx, em, cap) {
586-
let n = String(text || "").length;
587-
if (n === 0)
693+
let units = _textEmUnits(text, em);
694+
if (units <= 0)
588695
return cap;
589-
if (!(innerW > 0) || !(ptToPx > 0) || !(em > 0))
696+
if (!(innerW > 0) || !(ptToPx > 0))
590697
return 1;
591-
let pt = innerW / (n * ptToPx * em);
698+
let pt = innerW / (units * ptToPx);
592699
if (!Number.isFinite(pt) || pt <= 0)
593700
return 1;
594701
return Math.min(cap, pt);
595702
}
596703

704+
/**
705+
* worstTimeSample:
706+
* @format (string): strftime time format
707+
* @extras (object): { hundredths } to append a ".99" fraction
708+
*
709+
* Returns (string): a wide sample used to size the time line before the
710+
* live string is known, so enabling seconds or hundredths cannot overflow.
711+
*/
712+
function worstTimeSample(format, extras) {
713+
extras = extras || {};
714+
let fmt = typeof format === "string" ? format : "";
715+
let hasSeconds = !fmt || /%[STcT]/.test(fmt);
716+
let twelveHour = /%[IilpPr]/.test(fmt);
717+
let sample;
718+
if (twelveHour)
719+
sample = hasSeconds ? "12:59:59 PM" : "12:59 PM";
720+
else
721+
sample = hasSeconds ? "23:59:59" : "23:59";
722+
if (extras.hundredths)
723+
sample += ".99";
724+
return sample;
725+
}
726+
597727
/**
598728
* computeFittedFontSizes:
599729
* @innerWidth (number): card text box width in px

0 commit comments

Comments
 (0)