Skip to content

Commit 1382460

Browse files
stopwatch@KopfdesDaemons: Initial release (#1536)
1 parent 562d7e2 commit 1382460

11 files changed

Lines changed: 509 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
const Desklet = imports.ui.desklet;
2+
const Lang = imports.lang;
3+
const St = imports.gi.St;
4+
const Mainloop = imports.mainloop;
5+
const GLib = imports.gi.GLib;
6+
const Settings = imports.ui.settings;
7+
const Gettext = imports.gettext;
8+
const Clutter = imports.gi.Clutter;
9+
const GdkPixbuf = imports.gi.GdkPixbuf;
10+
const Cogl = imports.gi.Cogl;
11+
const Cairo = imports.cairo;
12+
13+
const UUID = "stopwatch@KopfdesDaemons";
14+
Gettext.bindtextdomain(UUID, GLib.get_home_dir() + "/.local/share/locale");
15+
16+
function _(str) {
17+
return Gettext.dgettext(UUID, str);
18+
}
19+
20+
class StopwatchDesklet extends Desklet.Desklet {
21+
constructor(metadata, deskletId) {
22+
super(metadata, deskletId);
23+
24+
// Initialize default properties
25+
this._timeout = null;
26+
this._animationTimeout = null;
27+
this._startTime = 0;
28+
this._elapsedTime = 0;
29+
this._isRunning = false;
30+
this.default_size = 180;
31+
this.buttonRow = null;
32+
this.playButton = null;
33+
this.pauseButton = null;
34+
this.stopButton = null;
35+
36+
// Use default values if settings are not yet set
37+
this.labelColor = "rgb(51, 209, 122)";
38+
this.scaleSize = 1;
39+
this.indicatorColor = "rgb(51, 209, 122)";
40+
this.rotationSpeed = 2;
41+
this.cricleWidth = 0.1;
42+
this.indicatorLength = 10;
43+
this.circleColor = "rgb(255, 255, 255)";
44+
45+
// Setup settings and bind them to properties
46+
const settings = new Settings.DeskletSettings(this, metadata["uuid"], deskletId);
47+
settings.bindProperty(Settings.BindingDirection.IN, "label-color", "labelColor", this._onSettingsChanged.bind(this));
48+
settings.bindProperty(Settings.BindingDirection.IN, "scale-size", "scaleSize", this._onSettingsChanged.bind(this));
49+
settings.bindProperty(Settings.BindingDirection.IN, "indicator-color", "indicatorColor", this._onSettingsChanged.bind(this));
50+
settings.bindProperty(Settings.BindingDirection.IN, "animation-speed", "rotationSpeed", this._onSettingsChanged.bind(this));
51+
settings.bindProperty(Settings.BindingDirection.IN, "circle-width", "cricleWidth", this._onSettingsChanged.bind(this));
52+
settings.bindProperty(Settings.BindingDirection.IN, "indicator-length", "indicatorLength", this._onSettingsChanged.bind(this));
53+
settings.bindProperty(Settings.BindingDirection.IN, "circle-color", "circleColor", this._onSettingsChanged.bind(this));
54+
55+
// Set the desklet header and build the layout
56+
this.setHeader(_("Stopwatch"));
57+
this._setupLayout();
58+
}
59+
60+
// Setup the entire visual layout of the desklet
61+
_setupLayout() {
62+
this.mainContainer = new St.Widget({
63+
layout_manager: new Clutter.BinLayout(),
64+
});
65+
this.setContent(this.mainContainer);
66+
67+
const absoluteSize = this.default_size * this.scaleSize;
68+
69+
// Create the circle actor for the canvas
70+
this.circleActor = new Clutter.Actor({
71+
width: absoluteSize,
72+
height: absoluteSize,
73+
});
74+
this.mainContainer.add_child(this.circleActor);
75+
76+
// Create a vertical box layout for the time and buttons
77+
this.centerContent = new St.BoxLayout({ vertical: true });
78+
this.mainContainer.add_child(this.centerContent);
79+
80+
// Create and style the time label
81+
this.timeLabel = new St.Label({
82+
text: "00.000",
83+
style: `font-size: ${20 * this.scaleSize}px; color: ${this.labelColor};`,
84+
});
85+
this.centerContent.add_child(new St.Bin({ child: this.timeLabel, x_align: St.Align.MIDDLE }));
86+
87+
// Create a horizontal box for the buttons
88+
this.buttonRow = new St.BoxLayout({ style: "spacing: 10px;" });
89+
this.centerContent.add_child(new St.Bin({ child: this.buttonRow, x_align: St.Align.MIDDLE }));
90+
91+
// Draw the static part of the circle and set up initial buttons
92+
this._updateVisuals();
93+
}
94+
95+
// Updates the visual properties based on current settings
96+
_updateVisuals() {
97+
const absoluteSize = this.default_size * this.scaleSize;
98+
this.circleActor.set_size(absoluteSize, absoluteSize);
99+
this.timeLabel.style = `font-size: ${20 * this.scaleSize}px; color: ${this.labelColor};`;
100+
this._drawCircle();
101+
this._updateButtons();
102+
}
103+
104+
// Updates the buttons based on the current scale size
105+
_updateButtons() {
106+
// Clear existing buttons
107+
this.buttonRow.destroy_all_children();
108+
109+
const buttonHeight = 40 * this.scaleSize;
110+
111+
const createButton = (iconName, callback) => {
112+
const icon = this._getImageAtScale(`${this.metadata.path}/${iconName}.svg`, buttonHeight, buttonHeight);
113+
const button = new St.Button({ child: icon });
114+
button.connect("clicked", callback.bind(this));
115+
return button;
116+
};
117+
118+
this.playButton = createButton("play", this._startStopwatch);
119+
this.pauseButton = createButton("pause", this._pauseStopwatch);
120+
this.stopButton = createButton("stop", this._resetStopwatch);
121+
122+
this.buttonRow.add_child(this.playButton);
123+
this.buttonRow.add_child(this.pauseButton);
124+
this.buttonRow.add_child(this.stopButton);
125+
126+
if (this._isRunning) {
127+
this.playButton.hide();
128+
this.pauseButton.show();
129+
} else {
130+
this.playButton.show();
131+
this.pauseButton.hide();
132+
}
133+
}
134+
135+
// Parses an RGB string to a RGBA array for Cairo
136+
_rgbToRgba(colorString) {
137+
const match = colorString.match(/\d+/g);
138+
if (match && match.length === 3) {
139+
return match.map(Number).map((c) => c / 255);
140+
}
141+
return [0.3, 0.8, 0.5]; // Default color if parsing fails
142+
}
143+
144+
// Helper to load and scale an SVG image
145+
_getImageAtScale(imageFileName, requestedWidth, requestedHeight) {
146+
try {
147+
const pixBuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imageFileName, requestedWidth, requestedHeight);
148+
const image = new Clutter.Image();
149+
image.set_data(
150+
pixBuf.get_pixels(),
151+
pixBuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGBA_888,
152+
pixBuf.get_width(),
153+
pixBuf.get_height(),
154+
pixBuf.get_rowstride()
155+
);
156+
return new Clutter.Actor({ content: image, width: pixBuf.get_width(), height: pixBuf.get_height() });
157+
} catch (e) {
158+
global.logError(`Error loading image ${imageFileName}: ${e}`);
159+
return new St.Label({ text: "Error" });
160+
}
161+
}
162+
163+
// Updates the time label every 10ms
164+
_updateTime() {
165+
const currentTime = new Date().getTime();
166+
const elapsedTime = this._elapsedTime + (this._isRunning ? currentTime - this._startTime : 0);
167+
const totalSeconds = Math.floor(elapsedTime / 1000);
168+
const hours = Math.floor(totalSeconds / 3600);
169+
const minutes = Math.floor(totalSeconds / 60) % 60;
170+
const seconds = totalSeconds % 60;
171+
const milliseconds = elapsedTime % 1000;
172+
173+
let formattedTime;
174+
if (hours > 0) {
175+
this.timeLabel.style = `font-size: ${16 * this.scaleSize}px; color: ${this.labelColor};`;
176+
formattedTime = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(
177+
milliseconds
178+
).padStart(3, "0")}`;
179+
} else if (minutes > 0) {
180+
formattedTime = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
181+
} else {
182+
this.timeLabel.style = `font-size: ${20 * this.scaleSize}px; color: ${this.labelColor};`;
183+
formattedTime = `${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
184+
}
185+
186+
this.timeLabel.set_text(formattedTime);
187+
return true;
188+
}
189+
190+
// Rotates the indicator actor for animation
191+
_animateIndicator() {
192+
this.circleActor.rotation_angle_z = (this.circleActor.rotation_angle_z + this.rotationSpeed) % 360;
193+
return true;
194+
}
195+
196+
// Starts the stopwatch
197+
_startStopwatch() {
198+
if (!this._isRunning) {
199+
this._startTime = new Date().getTime();
200+
this._timeout = Mainloop.timeout_add(10, this._updateTime.bind(this));
201+
this._isRunning = true;
202+
this.playButton.hide();
203+
this.pauseButton.show();
204+
this._animationTimeout = Mainloop.timeout_add(16, this._animateIndicator.bind(this));
205+
}
206+
}
207+
208+
// Pauses the stopwatch
209+
_pauseStopwatch() {
210+
if (this._isRunning) {
211+
if (this._timeout) {
212+
Mainloop.source_remove(this._timeout);
213+
this._timeout = null;
214+
}
215+
if (this._animationTimeout) {
216+
Mainloop.source_remove(this._animationTimeout);
217+
this._animationTimeout = null;
218+
}
219+
this._elapsedTime += new Date().getTime() - this._startTime;
220+
this._isRunning = false;
221+
this.playButton.show();
222+
this.pauseButton.hide();
223+
}
224+
}
225+
226+
// Resets the stopwatch to zero
227+
_resetStopwatch() {
228+
if (this._timeout) Mainloop.source_remove(this._timeout);
229+
this._timeout = null;
230+
if (this._animationTimeout) Mainloop.source_remove(this._animationTimeout);
231+
this._animationTimeout = null;
232+
233+
this.circleActor.rotation_angle_z = 0;
234+
this._startTime = 0;
235+
this._elapsedTime = 0;
236+
this._isRunning = false;
237+
this.timeLabel.style = `font-size: ${20 * this.scaleSize}px; color: ${this.labelColor};`;
238+
this.timeLabel.set_text("00.000");
239+
this.playButton.show();
240+
this.pauseButton.hide();
241+
}
242+
243+
// Callback for when settings are changed
244+
_onSettingsChanged() {
245+
const wasRunning = this._isRunning;
246+
if (wasRunning) {
247+
this._pauseStopwatch();
248+
}
249+
250+
// Update only the visual properties without destroying the layout
251+
this._updateVisuals();
252+
253+
if (wasRunning) {
254+
this._startStopwatch();
255+
}
256+
}
257+
258+
// Clean up timeouts when the desklet is removed
259+
on_desklet_removed() {
260+
if (this._timeout) {
261+
Mainloop.source_remove(this._timeout);
262+
this._timeout = null;
263+
}
264+
if (this._animationTimeout) {
265+
Mainloop.source_remove(this._animationTimeout);
266+
this._animationTimeout = null;
267+
}
268+
}
269+
270+
// Draws the static circle and arc on the canvas
271+
_drawCircle() {
272+
const canvas = new Clutter.Canvas();
273+
const absoluteSize = this.default_size * this.scaleSize;
274+
canvas.set_size(absoluteSize * global.ui_scale, absoluteSize * global.ui_scale);
275+
276+
canvas.connect("draw", (canvas, cr, width, height) => {
277+
cr.save();
278+
cr.setOperator(Cairo.Operator.CLEAR);
279+
cr.paint();
280+
cr.restore();
281+
cr.setOperator(Cairo.Operator.OVER);
282+
cr.scale(width, height);
283+
cr.translate(0.5, 0.5);
284+
285+
// Draw the background circle
286+
const rgbaCircle = this._rgbToRgba(this.circleColor);
287+
cr.setSourceRGBA(rgbaCircle[0], rgbaCircle[1], rgbaCircle[2], 0.2);
288+
cr.setLineWidth(this.cricleWidth);
289+
cr.arc(0, 0, 0.4, 0, Math.PI * 2);
290+
cr.stroke();
291+
292+
// Draw the indicator arc
293+
const rgbaIndicator = this._rgbToRgba(this.indicatorColor);
294+
cr.setSourceRGBA(rgbaIndicator[0], rgbaIndicator[1], rgbaIndicator[2], 1);
295+
cr.setLineWidth(this.cricleWidth);
296+
const arcEnd = (this.indicatorLength * (Math.PI * 2)) / 100 - Math.PI * 0.5;
297+
cr.arc(0, 0, 0.4, 0 - Math.PI * 0.5, arcEnd);
298+
cr.stroke();
299+
300+
return true;
301+
});
302+
303+
canvas.invalidate();
304+
this.circleActor.set_content(canvas);
305+
this.circleActor.set_pivot_point(0.5, 0.5);
306+
}
307+
}
308+
309+
// Entry point function for the desklet
310+
function main(metadata, deskletId) {
311+
return new StopwatchDesklet(metadata, deskletId);
312+
}
22.4 KB
Loading
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"uuid": "stopwatch@KopfdesDaemons",
3+
"name": "Stopwatch",
4+
"description": "A simple stopwatch.",
5+
"version": "1.0",
6+
"max-instances": "50"
7+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# STOPWATCH
2+
# This file is put in the public domain.
3+
# KopfdesDaemons, 2025
4+
#
5+
#, fuzzy
6+
msgid ""
7+
msgstr ""
8+
"Project-Id-Version: stopwatch@KopfdesDaemons 1.0\n"
9+
"Report-Msgid-Bugs-To: https://github.com/linuxmint/cinnamon-spices-desklets/"
10+
"issues\n"
11+
"POT-Creation-Date: 2025-08-03 11:24+0200\n"
12+
"PO-Revision-Date: \n"
13+
"Last-Translator: \n"
14+
"Language-Team: \n"
15+
"Language: de\n"
16+
"MIME-Version: 1.0\n"
17+
"Content-Type: text/plain; charset=UTF-8\n"
18+
"Content-Transfer-Encoding: 8bit\n"
19+
"X-Generator: Poedit 3.4.2\n"
20+
21+
#. metadata.json->name
22+
#. desklet.js:56
23+
msgid "Stopwatch"
24+
msgstr "Stoppuhr"
25+
26+
#. metadata.json->description
27+
msgid "A simple stopwatch."
28+
msgstr "Eine einfache Stoppuhr."
29+
30+
#. settings-schema.json->head0->description
31+
msgid "Style"
32+
msgstr "Stil"
33+
34+
#. settings-schema.json->label-color->description
35+
msgid "Label color"
36+
msgstr "Label Farbe"
37+
38+
#. settings-schema.json->scale-size->description
39+
msgid "Desklet size"
40+
msgstr "Desklet Größe"
41+
42+
#. settings-schema.json->head1->description
43+
msgid "Indicator"
44+
msgstr "Indikator"
45+
46+
#. settings-schema.json->indicator-color->description
47+
msgid "Indicator color"
48+
msgstr "Indikator Farbe"
49+
50+
#. settings-schema.json->circle-color->description
51+
msgid "Circle color"
52+
msgstr "Kreis Farbe"
53+
54+
#. settings-schema.json->animation-speed->description
55+
msgid "Animation speed"
56+
msgstr "Animationsgeschwindigkeit"
57+
58+
#. settings-schema.json->circle-width->description
59+
msgid "Circle width"
60+
msgstr "Kreisbreite"
61+
62+
#. settings-schema.json->indicator-length->description
63+
msgid "Indicator length"
64+
msgstr "Indikatorlänge"

0 commit comments

Comments
 (0)