|
| 1 | +const Desklet = imports.ui.desklet; |
| 2 | +const St = imports.gi.St; |
| 3 | +const GLib = imports.gi.GLib; |
| 4 | +const Gettext = imports.gettext; |
| 5 | +const Settings = imports.ui.settings; |
| 6 | +const Cairo = imports.cairo; |
| 7 | +const GdkPixbuf = imports.gi.GdkPixbuf; |
| 8 | +const Gdk = imports.gi.Gdk; |
| 9 | +const Clutter = imports.gi.Clutter; |
| 10 | +const Cogl = imports.gi.Cogl; |
| 11 | +const Gio = imports.gi.Gio; |
| 12 | + |
| 13 | +const UUID = "picture-frames@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 MyDesklet extends Desklet.Desklet { |
| 21 | + constructor(metadata, deskletId) { |
| 22 | + super(metadata, deskletId); |
| 23 | + this.defaultImagePath = this.metadata.path + "/images/default.jpg"; |
| 24 | + |
| 25 | + // Setup settings and bind them to properties |
| 26 | + const settings = new Settings.DeskletSettings(this, metadata["uuid"], deskletId); |
| 27 | + settings.bindProperty(Settings.BindingDirection.IN, "image-path", "imagePath", this._initUI.bind(this)); |
| 28 | + settings.bindProperty(Settings.BindingDirection.IN, "shape", "shape", this._initUI.bind(this)); |
| 29 | + settings.bindProperty(Settings.BindingDirection.IN, "size", "size", this._initUI.bind(this)); |
| 30 | + settings.bindProperty(Settings.BindingDirection.IN, "show-border", "showBorder", this._initUI.bind(this)); |
| 31 | + settings.bindProperty(Settings.BindingDirection.IN, "border-color", "borderColor", this._initUI.bind(this)); |
| 32 | + settings.bindProperty(Settings.BindingDirection.IN, "border-width", "borderWidth", this._initUI.bind(this)); |
| 33 | + settings.bindProperty(Settings.BindingDirection.IN, "waves-number", "wavesNumber", this._initUI.bind(this)); |
| 34 | + settings.bindProperty(Settings.BindingDirection.IN, "spikes-number", "spikesNumber", this._initUI.bind(this)); |
| 35 | + settings.bindProperty(Settings.BindingDirection.IN, "wave-depth", "waveDepth", this._initUI.bind(this)); |
| 36 | + settings.bindProperty(Settings.BindingDirection.IN, "spikes-depth", "spikesDepth", this._initUI.bind(this)); |
| 37 | + settings.bindProperty(Settings.BindingDirection.IN, "align-x", "alignX", this._initUI.bind(this)); |
| 38 | + settings.bindProperty(Settings.BindingDirection.IN, "align-y", "alignY", this._initUI.bind(this)); |
| 39 | + |
| 40 | + this.setHeader(_("Picture Frame")); |
| 41 | + this._initUI(); |
| 42 | + } |
| 43 | + |
| 44 | + _initUI() { |
| 45 | + const mainContainer = new St.BoxLayout({ vertical: true }); |
| 46 | + if (!this.imagePath) this.imagePath = this.defaultImagePath; |
| 47 | + |
| 48 | + const size = this.size; |
| 49 | + const finalImagePath = decodeURIComponent(this.imagePath.replace("file://", "")); |
| 50 | + const imageActor = this._createShapedImageActor(finalImagePath, size); |
| 51 | + |
| 52 | + mainContainer.add_child(imageActor); |
| 53 | + this.setContent(mainContainer); |
| 54 | + } |
| 55 | + |
| 56 | + _drawShapePath(cr, shape, centerX, centerY, radius) { |
| 57 | + switch (shape) { |
| 58 | + case "square": |
| 59 | + cr.rectangle(centerX - radius, centerY - radius, radius * 2, radius * 2); |
| 60 | + break; |
| 61 | + case "star": |
| 62 | + this._drawStarPath(cr, centerX, centerY, radius); |
| 63 | + break; |
| 64 | + case "wave": |
| 65 | + this._drawWavePath(cr, centerX, centerY, radius); |
| 66 | + break; |
| 67 | + case "heart": |
| 68 | + this._drawHeartPath(cr, centerX, centerY, radius); |
| 69 | + break; |
| 70 | + case "circle": |
| 71 | + default: |
| 72 | + cr.arc(centerX, centerY, radius, 0, 2 * Math.PI); |
| 73 | + cr.closePath(); |
| 74 | + break; |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + _drawWavePath(cr, centerX, centerY, radius, numWaves = this.wavesNumber, amplitude = this.waveDepth / 100) { |
| 79 | + const baseRadius = radius * (1 - amplitude); |
| 80 | + const waveAmplitude = radius * amplitude; |
| 81 | + const points = 100; // Number of points for a smooth curve |
| 82 | + |
| 83 | + // Move to the starting point |
| 84 | + const startAngle = 0; |
| 85 | + const startR = baseRadius + waveAmplitude * Math.sin(startAngle * numWaves); |
| 86 | + cr.moveTo(centerX + startR * Math.cos(startAngle), centerY + startR * Math.sin(startAngle)); |
| 87 | + |
| 88 | + for (let i = 1; i <= points; i++) { |
| 89 | + const angle = (i / points) * 2 * Math.PI; |
| 90 | + const r = baseRadius + waveAmplitude * Math.sin(angle * numWaves); |
| 91 | + cr.lineTo(centerX + r * Math.cos(angle), centerY + r * Math.sin(angle)); |
| 92 | + } |
| 93 | + cr.closePath(); |
| 94 | + } |
| 95 | + |
| 96 | + _drawStarPath(cr, centerX, centerY, radius, numSpikes = this.spikesNumber) { |
| 97 | + const angleStep = (2 * Math.PI) / (numSpikes * 2); |
| 98 | + const outerRadius = radius; |
| 99 | + const innerRadius = (radius * (100 - this.spikesDepth)) / 100; |
| 100 | + |
| 101 | + cr.moveTo(centerX + outerRadius, centerY); |
| 102 | + |
| 103 | + for (let i = 1; i <= numSpikes * 2; i++) { |
| 104 | + const currentRadius = i % 2 === 1 ? innerRadius : outerRadius; |
| 105 | + const angle = i * angleStep; |
| 106 | + const x = centerX + currentRadius * Math.cos(angle); |
| 107 | + const y = centerY + currentRadius * Math.sin(angle); |
| 108 | + cr.lineTo(x, y); |
| 109 | + } |
| 110 | + cr.closePath(); |
| 111 | + } |
| 112 | + |
| 113 | + _drawHeartPath(cr, centerX, centerY, radius) { |
| 114 | + const yOffset = radius * 0.2; // Offset to center the heart vertically |
| 115 | + const topY = centerY - radius * 0.4 - yOffset; |
| 116 | + const bottomY = centerY + radius - yOffset; |
| 117 | + const rightX = centerX + radius; |
| 118 | + const leftX = centerX - radius; |
| 119 | + const rightCp1X = centerX + radius * 1.5; |
| 120 | + const leftCp1X = centerX - radius * 1.5; |
| 121 | + const cp2Y = centerY - radius - yOffset; |
| 122 | + |
| 123 | + // Start at the bottom point |
| 124 | + cr.moveTo(centerX, bottomY); |
| 125 | + // Right side |
| 126 | + cr.curveTo(rightCp1X, centerY, rightX, cp2Y, centerX, topY); |
| 127 | + // Left side |
| 128 | + cr.curveTo(leftX, cp2Y, leftCp1X, centerY, centerX, bottomY); |
| 129 | + cr.closePath(); |
| 130 | + } |
| 131 | + |
| 132 | + _createShapedImageActor(imagePath, size) { |
| 133 | + const canvas = new Clutter.Canvas(); |
| 134 | + canvas.set_size(size, size); |
| 135 | + const actor = new Clutter.Actor({ width: size, height: size, content: canvas }); |
| 136 | + const file = Gio.file_new_for_path(imagePath); |
| 137 | + let pixbuf = null; |
| 138 | + |
| 139 | + canvas.connect("draw", (canvas, cr, width, height) => { |
| 140 | + // Clear the canvas |
| 141 | + cr.save(); |
| 142 | + cr.setOperator(Cairo.Operator.CLEAR); |
| 143 | + cr.paint(); |
| 144 | + cr.restore(); |
| 145 | + cr.setOperator(Cairo.Operator.OVER); |
| 146 | + |
| 147 | + if (pixbuf === null) { |
| 148 | + // Draw loading text |
| 149 | + cr.setSourceRGBA(1.0, 1.0, 1.0, 0.7); // Semi-transparent white |
| 150 | + cr.selectFontFace("sans-serif", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL); |
| 151 | + cr.setFontSize(20); |
| 152 | + const text = _("Loading..."); |
| 153 | + const extents = cr.textExtents(text); |
| 154 | + cr.moveTo(width / 2 - extents.width / 2, height / 2); |
| 155 | + cr.showText(text); |
| 156 | + } else { |
| 157 | + // Draw the shaped image once pixbuf is loaded |
| 158 | + this._drawFinalImage(cr, pixbuf, width, height); |
| 159 | + } |
| 160 | + return true; |
| 161 | + }); |
| 162 | + canvas.invalidate(); // Initial draw with "Loading..." |
| 163 | + |
| 164 | + file.read_async(GLib.PRIORITY_DEFAULT, null, (source, res) => { |
| 165 | + try { |
| 166 | + const stream = source.read_finish(res); |
| 167 | + GdkPixbuf.Pixbuf.new_from_stream_async(stream, null, (source, res) => { |
| 168 | + try { |
| 169 | + pixbuf = GdkPixbuf.Pixbuf.new_from_stream_finish(res); |
| 170 | + canvas.invalidate(); // Force a redraw now that the pixbuf is loaded |
| 171 | + } catch (e) { |
| 172 | + global.logError(`Error creating pixbuf from stream: ${e}`); |
| 173 | + } |
| 174 | + }); |
| 175 | + } catch (e) { |
| 176 | + global.logError(`Error reading file async: ${e}`); |
| 177 | + } |
| 178 | + }); |
| 179 | + return actor; |
| 180 | + } |
| 181 | + |
| 182 | + _drawFinalImage(cr, pixbuf, width, height) { |
| 183 | + try { |
| 184 | + // Preserve aspect ratio |
| 185 | + const originalWidth = pixbuf.get_width(); |
| 186 | + const originalHeight = pixbuf.get_height(); |
| 187 | + const aspect = originalWidth / originalHeight; |
| 188 | + |
| 189 | + let newWidth, newHeight; |
| 190 | + if (aspect > 1) { |
| 191 | + // Wider than tall |
| 192 | + newHeight = height; |
| 193 | + newWidth = height * aspect; |
| 194 | + } else { |
| 195 | + // Taller than wide or square |
| 196 | + newWidth = width; |
| 197 | + newHeight = width / aspect; |
| 198 | + } |
| 199 | + |
| 200 | + const scaledPixbuf = pixbuf.scale_simple(newWidth, newHeight, GdkPixbuf.InterpType.BILINEAR); |
| 201 | + const pixbufWithAlpha = scaledPixbuf.add_alpha(false, 0, 0, 0); |
| 202 | + |
| 203 | + cr.save(); |
| 204 | + this._drawShapePath(cr, this.shape, width / 2, height / 2, width / 2); |
| 205 | + cr.clip(); |
| 206 | + |
| 207 | + const drawX = (width - newWidth) * (this.alignX / 100); |
| 208 | + const drawY = (height - newHeight) * (this.alignY / 100); |
| 209 | + Gdk.cairo_set_source_pixbuf(cr, pixbufWithAlpha, drawX, drawY); |
| 210 | + cr.paint(); |
| 211 | + cr.restore(); |
| 212 | + |
| 213 | + if (this.showBorder) { |
| 214 | + const borderWidth = this.borderWidth; |
| 215 | + const [success, color] = Clutter.Color.from_string(this.borderColor); |
| 216 | + |
| 217 | + if (success) { |
| 218 | + cr.setSourceRGBA(color.red / 255, color.green / 255, color.blue / 255, color.alpha / 255); |
| 219 | + } else { |
| 220 | + // Fallback to white if color string is invalid |
| 221 | + cr.setSourceRGBA(1.0, 1.0, 1.0, 1.0); |
| 222 | + } |
| 223 | + cr.setLineWidth(borderWidth); |
| 224 | + this._drawShapePath(cr, this.shape, width / 2, height / 2, (width - borderWidth) / 2); |
| 225 | + cr.stroke(); |
| 226 | + } |
| 227 | + } catch (e) { |
| 228 | + global.logError(`Error drawing shaped image: ${e}`); |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + // Helper to create an actor from a Pixbuf |
| 233 | + _createActorFromPixbuf(pixBuf) { |
| 234 | + const pixelFormat = pixBuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGB_888; |
| 235 | + const image = new Clutter.Image(); |
| 236 | + image.set_data(pixBuf.get_pixels(), pixelFormat, pixBuf.get_width(), pixBuf.get_height(), pixBuf.get_rowstride()); |
| 237 | + |
| 238 | + return new Clutter.Actor({ |
| 239 | + content: image, |
| 240 | + width: pixBuf.get_width(), |
| 241 | + height: pixBuf.get_height(), |
| 242 | + }); |
| 243 | + } |
| 244 | + |
| 245 | + _getImageAtScale(imageFileName, requestedWidth, requestedHeight) { |
| 246 | + try { |
| 247 | + const pixBuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imageFileName, requestedWidth, requestedHeight); |
| 248 | + return this._createActorFromPixbuf(pixBuf); |
| 249 | + } catch (e) { |
| 250 | + global.logError(`Error loading image ${imageFileName}: ${e}`); |
| 251 | + return new St.Label({ text: "Error" + e.message, style_class: "picture-frame-error-label" }); |
| 252 | + } |
| 253 | + } |
| 254 | +} |
| 255 | + |
| 256 | +function main(metadata, deskletId) { |
| 257 | + return new MyDesklet(metadata, deskletId); |
| 258 | +} |
0 commit comments